Über Caches in ARM-Mikrocontrollern

BildHallo!



Im vorherigen Artikel haben wir einen Prozessor-Cache verwendet , um Grafiken auf einem Mikrocontroller in Embox zu beschleunigen . In diesem Fall haben wir den "Durchschreib" -Modus verwendet. Dann haben wir über einige der Vor- und Nachteile des "Durchschreibemodus" geschrieben, aber dies war nur eine flüchtige Übersicht. In diesem Artikel möchte ich, wie versprochen, die Arten von Caches in ARM-Mikrocontrollern genauer betrachten und vergleichen. All dies wird natürlich aus Sicht eines Programmierers betrachtet, und wir planen nicht, in diesem Artikel auf die Details des Speichercontrollers einzugehen.



Ich beginne mit der Stelle, an der ich im vorherigen Artikel aufgehört habe, nämlich dem Unterschied zwischen den Modi "Zurückschreiben" und "Durchschreiben", da diese beiden Modi am häufigsten verwendet werden. Zusamenfassend:



  • "Schreib zurück". Schreibdaten gehen nur in den Cache. Das eigentliche Schreiben in den Speicher wird verschoben, bis der Cache voll ist und Platz für neue Daten benötigt wird.
  • "Durchschreiben". Das Schreiben erfolgt "gleichzeitig" in den Cache und den Speicher.


Durchschreiben



Die Vorteile des Durchschreibens werden als benutzerfreundlich angesehen, wodurch möglicherweise Fehler reduziert werden. In diesem Modus befindet sich der Speicher immer im richtigen Zustand und erfordert keine zusätzlichen Aktualisierungsprozeduren.



Natürlich scheint dies einen großen Einfluss auf die Leistung zu haben, aber das STM selbst in diesem Dokument sagt, dass dies nicht der Fall ist:

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.
Das heißt, anfangs haben wir angenommen, dass die Leistung bei Schreibvorgängen, da das Schreiben in den Speicher erfolgt, ungefähr gleich ist wie ohne Cache, und der Hauptgewinn tritt aufgrund wiederholter Lesevorgänge auf. STM widerlegt dies jedoch und sagt, dass Daten im Speicher "im Hintergrund" angezeigt werden, sodass die Schreibleistung fast dieselbe ist wie im "Rückschreib" -Modus. Dies kann insbesondere von den internen Puffern des Speichercontrollers (FMC) abhängen.



Nachteile des "Durchschreib" -Modus:



  • Sequentieller und schneller Zugriff auf denselben Speicher kann die Leistung beeinträchtigen. Im "Rückschreib" -Modus sind sequentielle häufige Zugriffe auf denselben Speicher im Gegenteil ein Plus.
  • Wie im Fall des "Zurückschreibens" müssen Sie nach dem Ende der DMA-Operationen immer noch einen ungültigen Cache durchführen.
  • Fehler "Datenbeschädigung in einer Folge von Write-Through-Speichern und -Laden" in einigen Versionen von Cortex-M7. Einer der LVGL-Entwickler hat uns darauf hingewiesen .


Schreib zurück



Wie oben erwähnt, gelangen in diesem Modus (im Gegensatz zum "Durchschreiben") Daten im Allgemeinen nicht durch Schreiben in den Speicher, sondern nur in den Cache. Wie beim Durchschreiben hat diese Strategie zwei Unteroptionen: 1) Schreibzuweisung, 2) keine Schreibzuweisung. Wir werden weiter über diese Optionen sprechen.



Write Allocate



In der Regel wird "Lesezuweisung" in Caches immer verwendet. Bei einem Cache-Fehler zum Lesen werden Daten aus dem Speicher abgerufen und in den Cache gestellt. Ebenso kann ein Schreibfehler dazu führen, dass Daten in den Cache geladen werden ("Schreibzuweisung") oder nicht geladen werden ("Keine Schreibzuweisung").



Typischerweise werden in der Praxis die Kombinationen "Write-Back Write Allocate" oder "Write-Through No Write Allocate" verwendet. Weiter in den Tests werden wir versuchen, etwas detaillierter zu prüfen, in welchen Situationen "Schreibzuweisung" und in welchen "keine Schreibzuweisung" verwendet werden soll.



MPU



Bevor wir zum praktischen Teil übergehen, müssen wir herausfinden, wie die Parameter des Speicherbereichs eingestellt werden. Um den Cache-Modus für einen bestimmten Speicherbereich in der ARMv7-M-Architektur auszuwählen (oder zu deaktivieren), wird MPU (Memory Protection Unit) verwendet.



Der MPU-Controller unterstützt das Einstellen von Speicherbereichen. Insbesondere in der ARMV7-M-Architektur können bis zu 16 Regionen vorhanden sein. Für diese Regionen können Sie unabhängig festlegen: Startadresse, Größe, Zugriffsrechte (Lesen / Schreiben / Ausführen usw.), Attribute - TEX, zwischenspeicherbar, pufferbar, gemeinsam nutzbar sowie andere Parameter. Insbesondere mit einem solchen Mechanismus können Sie jede Art von Caching für eine bestimmte Region erreichen. Zum Beispiel können wir die Notwendigkeit beseitigen, cache_clean / cache_invalidate aufzurufen, indem wir einfach einen Speicherbereich für alle DMA-Operationen zuweisen und diesen Speicher als nicht zwischenspeicherbar markieren.



Ein wichtiger Punkt bei der Arbeit mit MPU:

Die Basisadresse, Größe und Attribute einer Region sind alle konfigurierbar, mit der allgemeinen Regel, dass alle Regionen natürlich ausgerichtet sind. Dies kann wie

folgt angegeben werden: RegionBaseAddress [(N-1): 0] = 0, wobei N log2 ist (SizeofRegion_in_bytes)
Mit anderen Worten muss die Startadresse des Speicherbereichs auf seine eigene Größe ausgerichtet werden. Wenn Sie beispielsweise eine 16-Kb-Region haben, müssen Sie diese um 16 Kb ausrichten. Wenn der Speicherbereich 64 KB beträgt, richten Sie ihn auf 64 KB aus. Usw. Andernfalls kann die MPU die Region automatisch auf die Größe „zuschneiden“, die ihrer Startadresse entspricht (in der Praxis getestet).



Übrigens gibt es in STM32Cube mehrere Fehler. Zum Beispiel:



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


Sie können sehen, dass die Startadresse 64 KB ausgerichtet ist. Die Größe der Region soll 256 KB betragen. In diesem Fall müssen Sie drei Regionen erstellen: die ersten 64 KB, die zweiten 128 KB und die dritten 64 KB.



Sie müssen nur Regionen angeben, die sich von den Standardeigenschaften unterscheiden. Tatsache ist, dass die Attribute aller Speicher, wenn der Prozessor-Cache aktiviert ist, in der ARM-Architektur beschrieben werden. Es gibt einen Standardsatz von Eigenschaften (aus diesem Grund verfügt der STM32F7-SRAM standardmäßig über einen "Write-Back-Write-Allocate" -Modus). Wenn Sie für einige Speicher einen nicht standardmäßigen Modus benötigen, müssen Sie seine Eigenschaften über die MPU festlegen. In diesem Fall können Sie innerhalb der Region eine Unterregion mit eigenen Eigenschaften festlegen. Wählen Sie innerhalb dieser Region eine andere mit hoher Priorität und den erforderlichen Eigenschaften aus.



TCM



Wie aus der Dokumentation (Abschnitt 2.3 Embedded SRAM) hervorgeht, können die ersten 64 KB SRAM in STM32F7 nicht zwischengespeichert werden. In der ARMv7-M-Architektur selbst befindet sich SRAM unter 0x20000000. TCM bezieht sich auch auf SRAM, befindet sich jedoch auf einem anderen Bus als die übrigen Speicher (SRAM1 und SRAM2) und "näher" am Prozessor. Aus diesem Grund ist dieser Speicher sehr schnell und hat tatsächlich die gleiche Geschwindigkeit wie der Cache. Aus diesem Grund ist kein Caching erforderlich, und diese Region kann nicht zwischengespeichert werden. Tatsächlich ist TCM ein weiterer solcher Cache.



Anweisungscache



Es ist zu beachten, dass sich alles, was oben diskutiert wurde, auf den Datencache (D-Cache) bezieht. Neben dem Datencache bietet ARMv7-M auch einen Anweisungscache - den Anweisungscache (I-Cache). Mit I-Cache können Sie einige der ausführbaren (und nachfolgenden) Anweisungen in den Cache übertragen, was das Programm erheblich beschleunigen kann. Insbesondere in Fällen, in denen sich der Code langsamer als FLASH befindet, z. B. QSPI.



Um die Unvorhersehbarkeit in den Tests mit dem folgenden Cache zu verringern, werden wir den I-Cache absichtlich deaktivieren und ausschließlich an die Daten denken.



Gleichzeitig möchte ich darauf hinweisen, dass das Aktivieren von I-Cache recht einfach ist und im Gegensatz zu D-Cache keine zusätzlichen Aktionen von der MPU erfordert.



Synthesetests



Nachdem wir den theoretischen Teil besprochen haben, fahren wir mit den Tests fort, um den Unterschied und den Umfang der Anwendbarkeit eines bestimmten Modells besser zu verstehen. Wie oben erwähnt, deaktivieren wir I-Cache und arbeiten nur mit D-Cache. Ich kompiliere auch absichtlich mit -O0, damit die Schleifen in den Tests nicht optimiert werden. Wir werden durch externen SDRAM-Speicher testen. Mit Hilfe der MPU habe ich die 64-KB-Region markiert, und wir werden die erforderlichen Attribute für diese Region verfügbar machen.



Da Tests mit Caches sehr launisch sind und von allem und jedem im System beeinflusst werden, machen wir den Code linear und kontinuierlich. Deaktivieren Sie dazu die Interrupts. Außerdem messen wir die Zeit nicht mit Timern, sondern mit DWT (Data Watchpoint and Trace Unit), das über einen 32-Bit-Zähler für Prozessorzyklen verfügt. Auf seiner Basis (im Internet) machen die Leute Mikrosekundenverzögerungen bei den Fahrern. Der Zähler läuft bei einer Systemfrequenz von 216 MHz schnell über, Sie können jedoch bis zu 20 Sekunden messen. Erinnern wir uns einfach daran und führen in diesem Zeitintervall Tests durch, bei denen der Taktzähler vor dem Start auf Null gestellt wird.



Sie können die komplette Testcodes sehen hier . Alle Tests wurden auf der 32F769IDISCOVERY-Karte durchgeführt .



Nicht zwischenspeicherbarer Speicher VS. Schreib zurück



Beginnen wir also mit einigen sehr einfachen Tests.



Wir schreiben nur konsequent in die Erinnerung.



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


Wir schreiben auch sequentiell in den Speicher, jedoch nicht byteweise, sondern erweitern die Schleifen ein wenig.



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


Wir schreiben auch sequentiell in den Speicher, aber jetzt werden wir auch das Lesen hinzufügen.



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


Wenn Sie alle drei Tests ausführen, erhalten Sie unabhängig vom gewählten Modus genau das gleiche Ergebnis:



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


Und das ist vernünftig, SDRAM ist nicht so langsam, besonders wenn man die internen Puffer des FMC betrachtet, über den es verbunden ist. Trotzdem habe ich eine leichte Abweichung in den Zahlen erwartet, aber es stellte sich heraus, dass dies bei diesen Tests nicht der Fall war. Nun, lass uns weiter überlegen.



Versuchen wir, das Leben von SDRAM durch Mischen von Lese- und Schreibvorgängen zu "verderben". Erweitern wir dazu die Schleifen und fügen in der Praxis so etwas wie das Inkrement eines Array-Elements hinzu:



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


Ergebnis:



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


Schon besser - mit dem Cache war es eine halbe Sekunde schneller. Versuchen wir, den Test noch komplizierter zu gestalten - fügen Sie den Zugriff durch "spärliche" Indizes hinzu. Zum Beispiel mit einem 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]++;
        }
    }


Ergebnis:



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


Jetzt ist der Unterschied zum Cache mehr als spürbar geworden! Und um das Ganze abzurunden, führen wir einen zweiten solchen Index ein:



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


Ergebnis:



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


Wir sehen, wie die Zeit für nicht zwischengespeicherten Speicher um fast eine Sekunde gewachsen ist, während sie für den Cache gleich bleibt.



Schreiben Sie VS zuweisen. kein Schreiben zuweisen



Lassen Sie uns nun den Modus "Schreibzuweisung" behandeln. Es ist noch schwieriger, den Unterschied hier zu erkennen, da Wenn sie in der Situation zwischen nicht zwischengespeichertem Speicher und "Zurückschreiben" bereits ab dem 4. Test deutlich sichtbar werden, wurden die Unterschiede zwischen "Schreibzuweisung" und "Keine Schreibzuweisung" durch die Tests noch nicht aufgedeckt. Denken wir mal - wann wird "Write Allocate" schneller sein? Zum Beispiel, wenn Sie viele Schreibvorgänge in sequentielle Speicherorte haben und nur wenige Lesevorgänge von diesen Speicherorten ausgeführt werden. In diesem Fall erhalten wir im Modus "Keine Schreibzuweisung" konstante Fehler, und es werden völlig falsche Elemente zum Lesen in den Cache geladen. Simulieren wir diese 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;
        }
    }


Hier werden 15 von 16 Datensätzen auf die VALUE-Konstante gesetzt, während das Lesen von verschiedenen (und nicht mit dem Schreiben zusammenhängenden) Elementen arr [i% 1024 + (j% 256) * 128] durchgeführt wird. Es stellt sich heraus, dass mit der Strategie "Keine Schreibzuweisung" nur diese Elemente in den Cache geladen werden. Der Grund, warum eine solche Indizierung verwendet wird (i% 1024 + (j% 256) * 128), ist die "Geschwindigkeitsverschlechterung" von FMC / SDRAM. Da Speicherzugriffe an erheblich unterschiedlichen (nicht sequentiellen) Adressen die Arbeitsgeschwindigkeit erheblich beeinflussen können.



Ergebnis:



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


Schließlich haben wir einen Unterschied, wenn auch nicht so auffällig, aber bereits sichtbar. Das heißt, unsere Hypothese wurde bestätigt.



Und schließlich der meiner Meinung nach schwierigste Fall. Wir möchten verstehen, wann „keine Schreibzuweisung“ besser ist als „Schreibzuweisung“. Das erste ist besser, wenn wir uns „oft“ auf Adressen beziehen, mit denen wir in naher Zukunft nicht arbeiten werden. Solche Daten müssen nicht zwischengespeichert werden.



Im nächsten Test werden im Fall von "Schreibzuweisung" die Daten beim Lesen und Schreiben gefüllt. Ich habe ein 64-KB-Array "arr2" erstellt, damit der Cache geleert wird, um neue Daten auszutauschen. Im Fall von "no write allocate" habe ich ein "arr" -Array von 4096 Bytes erstellt, und nur es wird in den Cache gelangen, was bedeutet, dass die Cache-Daten nicht in den Speicher geleert werden. Aus diesem Grund werden wir versuchen, zumindest einen kleinen Gewinn zu erzielen.



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


Ergebnis:



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


Es ist ersichtlich, dass der "Rückschreib" -Modus "Schreibzuweisung" etwas schneller ist. Hauptsache aber, es geht schneller.



Ich habe keine bessere Demonstration bekommen, aber ich bin sicher, dass es praktische Situationen gibt, in denen der Unterschied greifbarer ist. Leser können ihre eigenen Optionen vorschlagen!



Praktische Beispiele



Wechseln wir von synthetischen zu realen Beispielen.



Klingeln



Eines der einfachsten ist Ping. Der Start ist einfach und die Uhrzeit kann direkt auf dem Host angezeigt werden. Embox wurde mit der -O2-Optimierung erstellt. Ich werde sofort die Ergebnisse geben:



    :  ~0.246 c
Write-back                        :  ~0.140 c


Opencv



Ein weiteres Beispiel für ein echtes Problem, bei dem wir das Cache-Subsystem ausprobieren wollten, ist OpenCV auf STM32F7 . In diesem Artikel wurde gezeigt, dass der Start durchaus möglich war, die Leistung jedoch recht gering war. Zur Demonstration verwenden wir ein Standardbeispiel, das Ränder basierend auf dem Canny-Filter extrahiert. Messen wir die Laufzeit mit und ohne Caches (sowohl D-Cache als auch 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);


Ohne Cache:



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


Mit Cache:



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


Das heißt, die Beschleunigung von 926 ms und 134 ms beträgt fast das 7-fache.



Tatsächlich werden wir häufig nach OpenCV auf STM32 gefragt, insbesondere nach der Leistung. Es stellt sich heraus, dass FPS sicherlich nicht hoch ist, aber 5 Bilder pro Sekunde sind ziemlich realistisch zu bekommen.



Nicht zwischengespeicherter oder zwischengespeicherter Speicher, aber mit ungültigem Cache?



In realen Geräten ist DMA weit verbreitet, natürlich sind Schwierigkeiten damit verbunden, da Sie den Speicher auch für den "Durchschreibemodus" synchronisieren müssen. Es besteht der natürliche Wunsch, einfach ein nicht zwischengespeichertes Speicherelement zuzuweisen und es bei der Arbeit mit DMA zu verwenden. Ein wenig abgelenkt. Unter Linux erfolgt dies durch eine Funktion über dma_coherent_alloc () . Und ja, dies ist eine sehr effektive Methode. Wenn Sie beispielsweise mit Netzwerkpaketen im Betriebssystem arbeiten, durchlaufen Benutzerdaten eine große Verarbeitungsstufe, bevor Sie den Treiber erreichen, und im Treiber werden die vorbereiteten Daten mit allen Headern in Puffer kopiert, die nicht zwischengespeicherten Speicher verwenden.



Gibt es Fälle, in denen Clean / Inalidate bei einem Treiber mit DMA vorzuziehen ist? Ja, gibt es. Zum Beispiel Videospeicher, der uns dazu veranlassteSchauen Sie sich die Funktionsweise von cache () genauer an. Im Doppelpuffermodus verfügt das System über zwei Puffer, in die es nacheinander zieht und diese dann an den Videocontroller weitergibt. Wenn Sie einen solchen Speicher nicht zwischenspeicherbar machen, sinkt die Leistung. Daher ist es besser, eine Reinigung durchzuführen, bevor der Puffer an den Videocontroller gesendet wird.



Fazit



Wir haben ein wenig über die verschiedenen Arten von Caches in ARMv7m herausgefunden: Zurückschreiben, Durchschreiben sowie die Einstellungen "Schreibzuweisung" und "Keine Schreibzuweisung". Wir haben synthetische Tests erstellt, in denen wir herausfinden wollten, wann ein Modus besser ist als der andere, und auch praktische Beispiele mit Ping und OpenCV berücksichtigt. Bei Embox arbeiten wir gerade an diesem Thema, daher wird das entsprechende Subsystem noch ausgearbeitet. Die Vorteile der Verwendung von Caches sind jedoch auf jeden Fall spürbar.



Alle Beispiele können angezeigt und reproduziert werden, indem Embox aus dem offenen Repository erstellt wird.



PS



Wenn Sie sich für das Thema Systemprogrammierung und OSDev interessieren, findet morgen die OS Day- Konferenz statt ! Dieses Jahr ist es online, also verpassen Sie nicht diejenigen, die es wünschen! Embox wird morgen um 12.00 Uhr auftreten



All Articles