Kompilierungs-Caching

Ab Android 10 bietet die Neural Networks API (NNAPI) Funktionen zur Unterstützung des Caching von Kompilierungsartefakten, wodurch die für die Kompilierung benötigte Zeit beim Starten einer App reduziert wird. Bei Verwendung dieser Caching-Funktion muss der Treiber die Cache-Dateien nicht verwalten oder bereinigen. Dies ist eine optionale Funktion, die mit NN HAL 1.2 implementiert werden kann. Weitere Informationen zu dieser Funktion, siehe ANeuralNetworksCompilation_setCaching.

Der Treiber kann das Kompilierungs-Caching auch unabhängig von der NNAPI implementieren. Dies kann unabhängig davon implementiert werden, ob die NNAPI NDK- und HAL-Caching-Funktionen verwendet werden. AOSP bietet eine Low-Level-Dienstprogrammbibliothek (eine Caching-Engine). Weitere Informationen finden Sie unter Caching-Engine implementieren.

Workflowübersicht

In diesem Abschnitt werden allgemeine Workflows mit der implementierten Funktion für das Kompilierungs-Caching beschrieben.

Cache-Informationen bereitgestellt und Cache-Treffer

  1. Die App übergibt ein Caching-Verzeichnis und eine für das Modell eindeutige Prüfsumme.
  2. Die NNAPI-Laufzeit sucht anhand der Prüfsumme, der Ausführungseinstellung und des Partitionierungsergebnisses nach den Cache-Dateien und findet sie.
  3. Die NNAPI öffnet die Cache-Dateien und übergibt die Handles mit an den Treiber prepareModelFromCache.
  4. Der Treiber bereitet das Modell direkt aus den Cache-Dateien vor und gibt das vorbereitete Modell zurück.

Cache-Informationen bereitgestellt und Cache-Fehler

  1. Die App übergibt eine für das Modell eindeutige Prüfsumme und ein Caching-Verzeichnis.
  2. Die NNAPI-Laufzeit sucht anhand der Prüfsumme, der Ausführungseinstellung und des Partitionierungsergebnisses nach den Caching-Dateien und findet die Cache-Dateien nicht.
  3. Die NNAPI erstellt leere Cache-Dateien anhand der Prüfsumme, der Ausführung einstellung und der Partitionierung, öffnet die Cache-Dateien und übergibt die Handles und das Modell mit prepareModel_1_2 an den Treiber.
  4. Der Treiber kompiliert das Modell, schreibt Caching-Informationen in die Cache-Dateien und gibt das vorbereitete Modell zurück.

Keine Cache-Informationen bereitgestellt

  1. Die App ruft die Kompilierung auf, ohne Caching-Informationen bereitzustellen.
  2. Die App übergibt nichts im Zusammenhang mit dem Caching.
  3. Die NNAPI-Laufzeit übergibt das Modell mit prepareModel_1_2 an den Treiber.
  4. Der Treiber kompiliert das Modell und gibt das vorbereitete Modell zurück.

Cache-Informationen

Die Caching-Informationen, die einem Treiber bereitgestellt werden, bestehen aus einem Token und Cache-Dateihandles.

Token

Das Token ist ein Caching-Token der Länge Constant::BYTE_SIZE_OF_CACHE_TOKEN , das das vorbereitete Modell identifiziert. Dasselbe Token wird beim Speichern der Cache-Dateien mit prepareModel_1_2 und beim Abrufen des vorbereiteten Modells mit prepareModelFromCache bereitgestellt. Der Client des Treibers sollte ein Token mit einer niedrigen Kollisionsrate auswählen. Der Treiber kann keine Token-Kollision erkennen. Eine Kollision führt zu einer fehlgeschlagenen Ausführung oder zu einer erfolgreichen Ausführung, die falsche Ausgabewerte erzeugt.

Cache-Dateihandles (zwei Arten von Cache-Dateien)

Die beiden Arten von Cache-Dateien sind Datencache und Modellcache.

  • Datencache:Wird zum Caching konstanter Daten verwendet, einschließlich vorverarbeiteter und transformierter Tensorpuffer. Eine Änderung am Datencache sollte keine schlimmeren Auswirkungen haben als das Generieren falscher Ausgabewerte zur Ausführungszeit.
  • Modellcache:Wird zum Caching sicherheitsrelevanter Daten wie kompilierten ausführbaren Maschinencodes im nativen binären Format des Geräts verwendet. Eine Änderung am Modellcache kann sich auf das Ausführungsverhalten des Treibers auswirken. Ein böswilliger Client könnte dies nutzen, um über die gewährte Berechtigung hinaus auszuführen. Daher muss der Treiber prüfen, ob der Modellcache beschädigt ist, bevor er das Modell aus dem Cache vorbereitet. Weitere Informationen finden Sie unter Sicherheit.

Der Treiber muss entscheiden, wie Cache-Informationen auf die beiden Arten von Cache-Dateien verteilt werden, und mit getNumberOfCacheFilesNeededangeben, wie viele Cache-Dateien für jeden Typ erforderlich sind.

Die NNAPI-Laufzeit öffnet Cache-Dateihandles immer mit Lese- und Schreibberechtigung.

Sicherheit

Beim Kompilierungs-Caching kann der Modellcache sicherheitsrelevante Daten wie kompilierten ausführbaren Maschinencode im nativen binären Format des Geräts enthalten. Wenn er nicht ordnungsgemäß geschützt ist, kann sich eine Änderung am Modellcache auf das Ausführungsverhalten des Treibers auswirken. Da die Cache-Inhalte im App-Verzeichnis gespeichert werden, können die Cache-Dateien vom Client geändert werden. Ein fehlerhafter Client kann den Cache versehentlich beschädigen. Ein böswilliger Client könnte dies absichtlich nutzen, um nicht überprüften Code auf dem Gerät auszuführen. Je nach den Eigenschaften des Geräts kann dies ein Sicherheitsproblem darstellen. Daher muss der Treiber potenzielle Beschädigungen des Modellcaches erkennen können, bevor er das Modell aus dem Cache vorbereitet.

Eine Möglichkeit hierfür ist, dass der Treiber eine Zuordnung vom Token zu einem kryptografischen Hash des Modellcaches verwaltet. Der Treiber kann das Token und den Hash des Modellcaches speichern, wenn er die Kompilierung im Cache speichert. Der Treiber prüft den neuen Hash des Modellcaches mit dem aufgezeichneten Token- und Hash-Paar, wenn er die Kompilierung aus dem Cache abruft. Diese Zuordnung sollte auch nach einem Neustart des Systems bestehen bleiben. Der Treiber kann den Android-Keystore-Dienst, die Dienstprogrammbibliothek in framework/ml/nn/driver/cache, oder einen anderen geeigneten Mechanismus verwenden, um einen Zuordnungsmanager zu implementieren. Bei einem Treiberupdate sollte dieser Zuordnungsmanager neu initialisiert werden, um zu verhindern, dass Cache-Dateien aus einer früheren Version vorbereitet werden.

Um TOCTOU-Angriffe (Time-of-Check to Time-of-Use) zu verhindern, muss der Treiber den aufgezeichneten Hash berechnen, bevor er ihn in einer Datei speichert, und den neuen Hash berechnen, nachdem er den Dateiinhalt in einen internen Puffer kopiert hat.

Dieser Beispielcode zeigt, wie diese Logik implementiert wird.

bool saveToCache(const sp<V1_2::IPreparedModel> preparedModel,
                 const hidl_vec<hidl_handle>& modelFds, const hidl_vec<hidl_handle>& dataFds,
                 const HidlToken& token) {
    // Serialize the prepared model to internal buffers.
    auto buffers = serialize(preparedModel);

    // This implementation detail is important: the cache hash must be computed from internal
    // buffers instead of cache files to prevent time-of-check to time-of-use (TOCTOU) attacks.
    auto hash = computeHash(buffers);

    // Store the {token, hash} pair to a mapping manager that is persistent across reboots.
    CacheManager::get()->store(token, hash);

    // Write the cache contents from internal buffers to cache files.
    return writeToFds(buffers, modelFds, dataFds);
}

sp<V1_2::IPreparedModel> prepareFromCache(const hidl_vec<hidl_handle>& modelFds,
                                          const hidl_vec<hidl_handle>& dataFds,
                                          const HidlToken& token) {
    // Copy the cache contents from cache files to internal buffers.
    auto buffers = readFromFds(modelFds, dataFds);

    // This implementation detail is important: the cache hash must be computed from internal
    // buffers instead of cache files to prevent time-of-check to time-of-use (TOCTOU) attacks.
    auto hash = computeHash(buffers);

    // Validate the {token, hash} pair by a mapping manager that is persistent across reboots.
    if (CacheManager::get()->validate(token, hash)) {
        // Retrieve the prepared model from internal buffers.
        return deserialize<V1_2::IPreparedModel>(buffers);
    } else {
        return nullptr;
    }
}

Komplexere Anwendungsfälle

In bestimmten komplexeren Anwendungsfällen benötigt ein Treiber nach dem Kompilierungsaufruf Zugriff auf den Cache-Inhalt (Lesen oder Schreiben). Beispiel-Anwendungsfälle umfassen Folgendes:

  • Just-in-Time-Kompilierung:Die Kompilierung wird bis zur ersten Ausführung verzögert.
  • Mehrstufige Kompilierung:Zuerst wird eine schnelle Kompilierung durchgeführt. Eine optionale optimierte Kompilierung erfolgt später, je nach Nutzungshäufigkeit.

Damit der Treiber nach dem Kompilierungsaufruf auf den Cache-Inhalt zugreifen kann (Lesen oder Schreiben), muss er Folgendes tun:

  • Die Dateihandles während des Aufrufs von prepareModel_1_2 oder prepareModelFromCache duplizieren und den Cache-Inhalt später lesen/aktualisieren.
  • Eine Logik für die Dateisperrung außerhalb des normalen Kompilierungsaufrufs implementieren, um zu verhindern, dass ein Schreibvorgang gleichzeitig mit einem Lesevorgang oder einem anderen Schreibvorgang erfolgt.

Caching-Engine implementieren

Neben der NN HAL 1.2-Schnittstelle für das Kompilierungs-Caching finden Sie im frameworks/ml/nn/driver/cache Verzeichnis auch eine Dienstprogrammbibliothek für das Caching. Das nnCache Unterverzeichnis enthält Code für den nichtflüchtigen Speicher, mit dem der Treiber das Kompilierungs-Caching implementieren kann, ohne die NNAPI-Caching-Funktionen zu verwenden. Diese Form des Kompilierungs-Caching kann mit jeder Version von NN HAL implementiert werden. Wenn der Treiber das Caching unabhängig von der HAL-Schnittstelle implementiert, ist er dafür verantwortlich, die Cache-Artefakte freizugeben, wenn sie nicht mehr benötigt werden.