À propos des caches dans les microcontrôleurs ARM

imagesalut!



Dans l' article précédent , nous avons utilisé un cache de processeur pour accélérer les graphiques sur un microcontrôleur dans Embox . Dans ce cas, nous avons utilisé le mode «écriture immédiate». Ensuite, nous avons décrit certains des avantages et des inconvénients associés au mode «écriture immédiate», mais ce n'était qu'un aperçu superficiel. Dans cet article, comme promis, je souhaite examiner de plus près les types de caches dans les microcontrôleurs ARM, ainsi que les comparer. Bien sûr, tout cela sera considéré du point de vue d'un programmeur, et nous ne prévoyons pas d'entrer dans les détails du contrôleur de mémoire dans cet article.



Je vais commencer par là où je me suis arrêté dans l'article précédent, à savoir la différence entre les modes "écriture différée" et "écriture directe", puisque ces deux modes sont le plus souvent utilisés. En bref:



  • "Réécriture". Les données d'écriture vont uniquement dans le cache. L'écriture réelle dans la mémoire est différée jusqu'à ce que le cache soit plein et que de l'espace soit requis pour les nouvelles données.
  • "Écriture immédiate". L'écriture se produit «simultanément» dans le cache et la mémoire.


Écriture directe



Les avantages de l'écriture directe sont considérés comme la facilité d'utilisation, ce qui réduit potentiellement les erreurs. En effet, dans ce mode la mémoire est toujours dans le bon état et ne nécessite pas de procédures de mise à jour supplémentaires.



Bien sûr, il semble que cela devrait avoir un impact important sur les performances, mais le STM lui-même dans ce document dit que ce n'est pas le cas:

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.
Autrement dit, au départ, nous avons supposé que puisque l'écriture est en mémoire, les performances des opérations d'écriture seront à peu près les mêmes que sans cache du tout, et le gain principal se produit en raison de lectures répétées. Cependant, STM réfute cela, il dit que les données en mémoire sont «en arrière-plan», de sorte que les performances d'écriture sont presque les mêmes qu'en mode «réécriture». Ceci, en particulier, peut dépendre des tampons internes du contrôleur de mémoire (FMC).



Inconvénients du mode "écriture immédiate":



  • Un accès séquentiel et rapide à la même mémoire peut dégrader les performances. En mode "write-back", les accès fréquents séquentiels à la même mémoire seront au contraire un plus.
  • Comme dans le cas de la "réécriture", vous devez toujours faire une invalidation du cache après la fin des opérations DMA.
  • Bug «corruption de données dans une séquence de stockage et de chargement en écriture immédiate» dans certaines versions de Cortex-M7. Cela nous a été signalé par l'un des développeurs LVGL.


Réécriture



Comme mentionné ci-dessus, dans ce mode (par opposition à "écriture directe") les données dans le cas général n'entrent pas en mémoire par écriture, mais uniquement dans le cache. Comme l'écriture directe, cette stratégie comporte deux sous-options - 1) allocation d'écriture, 2) aucune allocation d'écriture. Nous parlerons plus loin de ces options.



Ecrire Allouer



En règle générale, «allouer en lecture» est toujours utilisé dans les caches - c'est-à-dire qu'en cas de non-lecture du cache, les données sont extraites de la mémoire et placées dans le cache. De même, un échec d'écriture peut entraîner le chargement des données dans le cache ("allocation d'écriture") ou le non-chargement ("pas d'allocation d'écriture").



Généralement, en pratique, les combinaisons «allocation d'écriture différée» ou «allocation d'écriture directe sans écriture» sont utilisées. Plus loin dans les tests, nous essaierons de vérifier un peu plus en détail dans quelles situations utiliser "write allocate", et dans quelles "no write allocate".



MPU



Avant de passer à la partie pratique, nous devons comprendre comment définir les paramètres de la région mémoire. Pour sélectionner le mode cache (ou le désactiver) pour une région spécifique de la mémoire dans l'architecture ARMv7-M, MPU (Memory Protection Unit) est utilisé.



Le contrôleur MPU prend en charge la définition des régions de mémoire. Plus précisément, dans l'architecture ARMV7-M, il peut y avoir jusqu'à 16 régions. Pour ces régions, vous pouvez définir indépendamment: l'adresse de démarrage, la taille, les droits d'accès (lecture / écriture / exécution, etc.), les attributs - TEX, pouvant être mis en cache, tamponnable, partageable, ainsi que d'autres paramètres. En utilisant un tel mécanisme, en particulier, vous pouvez réaliser tout type de mise en cache pour une région spécifique. Par exemple, nous pouvons nous débarrasser de la nécessité d'appeler cache_clean / cache_invalidate en allouant simplement une région de mémoire pour toutes les opérations DMA et en marquant cette mémoire comme non-cache.



Un point important à noter lorsque vous travaillez avec MPU:

L'adresse de base, la taille et les attributs d'une région sont tous configurables, avec la règle générale que toutes les régions sont naturellement alignées. Cela peut être indiqué comme

suit : RegionBaseAddress [(N-1): 0] = 0, où N est log2 (SizeofRegion_in_bytes)
En d'autres termes, l'adresse de départ de la région mémoire doit être alignée sur sa propre taille. Si, par exemple, vous avez une région de 16 Ko, vous devez l'aligner sur 16 Ko. Si la région de mémoire est de 64 Ko, alors alignez-vous sur 64 Ko. Etc. Si cela n'est pas fait, le MPU peut automatiquement «recadrer» la région à la taille correspondant à son adresse de départ (testée en pratique).



À propos, il y a plusieurs bogues dans STM32Cube. Par exemple:



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


Vous pouvez voir que l'adresse de départ est alignée sur 64 Ko. Et nous voulons que la taille de la région soit de 256 Ko. Dans ce cas, vous devrez créer 3 régions: la première 64 Ko, la deuxième 128 Ko et la troisième 64 Ko.



Il vous suffit de spécifier des régions avec des propriétés différentes des propriétés standard. Le fait est que les attributs de toutes les mémoires lorsque le cache du processeur est activé sont décrits dans l'architecture ARM. Il existe un ensemble standard de propriétés (par exemple, c'est pourquoi la SRAM STM32F7 a un mode «écriture-réécriture-allocation» par défaut). Par conséquent, si vous avez besoin d'un mode non standard pour certaines mémoires, vous devrez définir ses propriétés via MPU. Dans ce cas, dans la région, vous pouvez définir une sous-région avec ses propres propriétés, en mettant en évidence dans cette région une autre avec une priorité élevée avec les propriétés requises.



TCM



Comme il ressort de la documentation (section 2.3 SRAM intégrée), les 64 premiers Ko de SRAM dans STM32F7 ne peuvent pas être mis en cache. Dans l'architecture ARMv7-M elle-même, SRAM est situé à 0x20000000. TCM fait également référence à SRAM, mais se trouve sur un bus différent du reste des mémoires (SRAM1 et SRAM2), et est situé «plus près» du processeur. Pour cette raison, cette mémoire est très rapide, en fait, a la même vitesse que le cache. Et pour cette raison, la mise en cache n'est pas nécessaire et cette région ne peut pas être mise en cache. En fait, TCM est un autre cache de ce type.



Cache d'instructions



Il convient de noter que tout ce qui a été discuté ci-dessus fait référence au cache de données (D-Cache). Mais en plus du cache de données, ARMv7-M fournit également un cache d'instructions - Cache d'instructions (I-Cache). I-Cache vous permet de transférer certaines des instructions exécutables (et suivantes) vers le cache, ce qui peut considérablement accélérer le programme. Surtout dans les cas où le code est en mémoire plus lente que FLASH, par exemple, QSPI.



Pour réduire l'imprévisibilité des tests avec le cache ci-dessous, nous désactiverons intentionnellement I-Cache et penserons exclusivement aux données.



Dans le même temps, je tiens à noter que l'activation de I-Cache est assez simple et ne nécessite aucune action supplémentaire de la part du MPU, contrairement à D-Cache.



Tests synthétiques



Après avoir discuté de la partie théorique, passons aux tests pour mieux comprendre la différence et la portée d'applicabilité d'un modèle particulier. Comme je l'ai dit ci-dessus, nous désactivons I-Cache et ne travaillons qu'avec D-Cache. Je compile aussi intentionnellement avec -O0 pour que les boucles des tests ne soient pas optimisées. Nous allons tester via la mémoire SDRAM externe. Avec l'aide de MPU, j'ai marqué la région de 64 Ko, et nous exposerons les attributs dont nous avons besoin pour cette région.



Puisque les tests avec des caches sont très capricieux et sont influencés par tout et chacun dans le système, rendons le code linéaire et continu. Pour ce faire, désactivez les interruptions. De plus, nous ne mesurerons pas le temps avec des minuteries, mais DWT (Data Watchpoint and Trace unit), qui dispose d'un compteur 32 bits de cycles de processeur. Sur sa base (sur Internet), les gens font des retards de l'ordre de la microseconde chez les conducteurs. Le compteur déborde rapidement à la fréquence système de 216 MHz, mais vous pouvez mesurer jusqu'à 20 secondes. Souvenons-nous simplement de cela et faisons des tests dans cet intervalle de temps, en pré-zéro le compteur d'horloge avant de commencer.



Vous pouvez voir les codes de test complets ici . Tous les tests ont été réalisés sur la carte 32F769IDISCOVERY .



Mémoire non cache VS. réécriture



Commençons donc par quelques tests très simples.



Nous écrivons simplement en mémoire.



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


Nous écrivons également séquentiellement dans la mémoire, mais pas un octet à la fois, mais étendons un peu les boucles.



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


Nous écrivons également séquentiellement dans la mémoire, mais maintenant nous allons également ajouter la lecture.



    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 vous exécutez ces trois tests, ils donneront exactement le même résultat, quel que soit le mode choisi:



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


Et c'est raisonnable, la SDRAM n'est pas si lente, surtout si l'on considère les tampons internes du FMC à travers lesquels il est connecté. Néanmoins, je m'attendais à une légère variation des chiffres, mais il s'est avéré que ce n'était pas sur ces tests. Eh bien, réfléchissons plus loin.



Essayons de «gâcher» la vie de la SDRAM en mélangeant les lectures et les écritures. Pour ce faire, développons les boucles et ajoutons une chose aussi courante dans la pratique que l'incrémentation d'un élément de tableau:



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


Résultat:



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


Déjà mieux - avec le cache, il s'est avéré être une demi-seconde plus rapide. Essayons de compliquer encore plus le test - ajoutez un accès par des index «épars». Par exemple, avec un index:



    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]++;
        }
    }


Résultat:



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


Maintenant, la différence avec le cache est devenue plus que perceptible! Et pour couronner le tout, nous introduisons un deuxième index de ce type:



    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]++;
        }
    }


Résultat:



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


Nous voyons comment le temps de mémoire non mise en cache a augmenté de près d'une seconde, tandis que pour le cache, il reste le même.



Écrivez allouer VS. pas d'écriture allouer



Traitons maintenant du mode "écriture d'allocation". Il est encore plus difficile de voir la différence ici, car si dans la situation entre mémoire non mise en cache et «réécriture» elles deviennent clairement visibles dès le 4e test, les différences entre «allouer d'écriture» et «pas d'allouer d'écriture» n'ont pas encore été révélées par les tests. Réfléchissons - quand «écrire allouer» sera plus rapide? Par exemple, lorsque vous avez de nombreuses écritures dans des emplacements de mémoire séquentiels et qu'il y a peu de lectures à partir de ces emplacements de mémoire. Dans ce cas, en mode «pas d'allocation d'écriture», nous recevrons des échecs constants, et les mauvais éléments seront chargés dans le cache par lecture. Simulons cette situation:



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


Ici, 15 enregistrements sur 16 sont définis sur la constante VALUE, tandis que la lecture est effectuée à partir d'éléments différents (et non liés à l'écriture) arr [i% 1024 + (j% 256) * 128]. Il s'avère qu'avec la stratégie d'allocation sans écriture, seuls ces éléments seront chargés dans le cache. La raison pour laquelle une telle indexation est utilisée (i% 1024 + (j% 256) * 128) est la «dégradation de la vitesse» de FMC / SDRAM. Étant donné que les accès à la mémoire à des adresses significativement différentes (non séquentielles) peuvent affecter considérablement la vitesse de travail.



Résultat:



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


Enfin, nous avons eu une différence, certes pas si perceptible, mais déjà visible. Autrement dit, notre hypothèse a été confirmée.



Et enfin, le cas le plus difficile, à mon avis. Nous voulons comprendre quand «pas d'allocation d'écriture» est mieux que «d'allocation d'écriture». La première est meilleure si nous nous référons «souvent» à des adresses avec lesquelles nous ne travaillerons pas dans un proche avenir. Ces données n'ont pas besoin d'être mises en cache.



Dans le test suivant, dans le cas de "write allocate", les données seront remplies en lecture et en écriture. J'ai fait un tableau de 64 Ko "arr2", donc le cache sera vidé pour échanger de nouvelles données. Dans le cas de «pas d'allocation d'écriture», j'ai créé un tableau «arr» de 4096 octets, et seul il entrera dans le cache, ce qui signifie que les données du cache ne seront pas vidées en mémoire. Pour cette raison, nous essaierons d'obtenir au moins une petite victoire.



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


Résultat:



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


On peut voir que le mode "write-back" "write allocate" est légèrement plus rapide. Mais l'essentiel est que ce soit plus rapide.



Je n'ai pas eu de meilleure démonstration, mais je suis sûr qu'il y a des situations pratiques où la différence est plus tangible. Les lecteurs peuvent suggérer leurs propres options!



Exemples pratiques



Passons des exemples synthétiques aux exemples réels.



ping



L'un des plus simples est le ping. Il est facile de démarrer et l'heure peut être visualisée directement sur l'hôte. Embox a été construit avec l'optimisation -O2. Je donnerai immédiatement les résultats:



    :  ~0.246 c
Write-back                        :  ~0.140 c


Opencv



Un autre exemple de problème réel sur lequel nous voulions essayer le sous-système de cache est OpenCV sur STM32F7 . Dans cet article, il a été montré qu'il était tout à fait possible de lancer, mais les performances étaient plutôt faibles. Pour la démonstration, nous utiliserons un exemple standard qui extrait les bordures à partir du filtre Canny. Mesurons le temps d'exécution avec et sans caches (à la fois D-cache et 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);


Sans cache:



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


Avec cache:



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


Autrement dit, l'accélération de 926 ms et 134 ms est presque 7 fois.



En fait, on nous pose souvent des questions sur OpenCV sur STM32, en particulier, quelles sont les performances. Il s'avère que le FPS n'est certainement pas élevé, mais 5 images par seconde sont assez réalistes à obtenir.



Pas de mémoire cache ou mise en cache, mais avec le cache invalidé?



Dans les appareils réels, le DMA est largement utilisé, naturellement, des difficultés y sont associées, car vous devez synchroniser la mémoire même pour le mode «écriture immédiate». Il existe un désir naturel d'allouer simplement un morceau de mémoire qui ne sera pas mis en cache et de l'utiliser lorsque vous travaillez avec DMA. Un peu distrait. Sous Linux, cela se fait par une fonction via dma_coherent_alloc () . Et oui, c'est une méthode très efficace, par exemple, lorsque vous travaillez avec des paquets réseau dans le système d'exploitation, les données utilisateur passent par une grande étape de traitement avant d'atteindre le pilote, et dans le pilote, les données préparées avec tous les en-têtes sont copiées dans des tampons qui utilisent une mémoire non mise en cache.



Existe-t-il des cas où nettoyer / invalider est préférable dans un pilote avec DMA? Oui il y a. Par exemple, la mémoire vidéo, qui nous a incitéregardez de plus près comment fonctionne cache (). En mode double tamponnage, le système dispose de deux tampons, dans lesquels il puise à son tour, puis les donne au contrôleur vidéo. Si vous rendez une telle mémoire non mise en cache, il y aura une baisse des performances. Par conséquent, il est préférable de faire un nettoyage avant d'envoyer le tampon au contrôleur vidéo.



Conclusion



Nous avons un peu compris les différents types de caches dans ARMv7m: écriture différée, écriture directe, ainsi que les paramètres «d'allocation d'écriture» et «pas d'allocation d'écriture». Nous avons construit des tests synthétiques dans lesquels nous avons essayé de savoir quand un mode est meilleur que l'autre, et avons également examiné des exemples pratiques avec ping et OpenCV. Chez Embox, nous ne travaillons que sur ce sujet, donc le sous-système correspondant est toujours en cours d'élaboration. Les avantages de l'utilisation des caches sont cependant manifestes.



Tous les exemples peuvent être visualisés et reproduits en construisant Embox à partir du référentiel ouvert.



PS



Si vous êtes intéressé par le thème de la programmation système et OSDev, alors la conférence OS Day aura lieu demain ! Cette année, il est en ligne, alors ne manquez pas ceux qui le souhaitent! Embox jouera demain à 12h00



All Articles