Kompilierungs-Caching

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

Der Treiber kann auch das Kompilierungs-Caching unabhängig von der NNAPI implementieren. Dies kann unabhängig davon implementiert werden, ob die NNAPI NDK- und HAL-Caching-Funktionen verwendet werden oder nicht. 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 implementiertem Feature für das Kompilierungs-Caching beschrieben.

Cache-Informationen und Cache-Treffer

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

Cache-Informationen und Cache-Fehler

  1. Die Anwendung übergibt eine für das Modell eindeutige Prüfsumme und ein Caching-Verzeichnis.
  2. Die NNAPI-Laufzeit sucht anhand der Prüfsumme, der Ausführungspräferenz und des Partitionierungsergebnisses nach den Caching-Dateien und findet die Cache-Dateien nicht.
  3. Die NNAPI erstellt leere Cache-Dateien basierend auf der Prüfsumme, der Ausführungseinstellung 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.

Cache-Informationen nicht angegeben

  1. Die Anwendung ruft die Kompilierung auf, ohne Caching-Informationen bereitzustellen.
  2. Die Anwendung übergibt nichts im Zusammenhang mit 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-Datei-Handles.

Token

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

Handles für Cache-Dateien (zwei Arten von Cache-Dateien)

Die beiden Arten von Cache-Dateien sind der Daten-Cache und der Modell-Cache.

  • Daten-Cache: Wird zum Caching konstanter Daten verwendet, einschließlich vorverarbeiteter und transformierter Tensor-Zwischenspeicher. Eine Änderung des Daten-Cache sollte sich nicht verschlechtern als die Erzeugung ungültiger Ausgabewerte bei der Ausführung.
  • Modell-Cache:Wird zum Speichern sicherheitsrelevanter Daten wie kompilierter ausführbarer Maschinencode im nativen Binärformat des Geräts verwendet. Eine Änderung am Modell-Cache kann sich auf das Ausführungsverhalten des Treibers auswirken. Ein böswilliger Client könnte dies nutzen, um über die erteilte Berechtigung hinaus auszuführen. Daher muss der Treiber prüfen, ob der Modellcache beschädigt ist, bevor das Modell aus dem Cache vorbereitet wird. Weitere Informationen finden Sie unter Sicherheit.

Der Treiber muss entscheiden, wie Cache-Informationen auf die beiden Arten von Cache-Dateien verteilt werden, und mit getNumberOfCacheFilesNeeded melden, wie viele Cache-Dateien er für jeden Typ benötigt.

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

Sicherheit

Beim Kompilierungs-Caching kann der Modell-Cache sicherheitsrelevante Daten wie kompilierten ausführbaren Maschinencode im nativen Binärformat des Geräts enthalten. Wenn der Modell-Cache nicht ordnungsgemäß geschützt ist, kann sich eine Änderung am Modell-Cache auf das Ausführungsverhalten des Treibers auswirken. Da der Cache-Inhalt im Anwendungsverzeichnis gespeichert wird, können die Cache-Dateien vom Client geändert werden. Ein fehlerhafter Client kann versehentlich den Cache beschädigen und ein böswilliger Client könnte diesen Code absichtlich nutzen, um nicht verifizierten Code auf dem Gerät auszuführen. Je nach Geräteeigenschaften kann dies ein Sicherheitsproblem sein. Daher muss der Treiber eine potenzielle Beschädigung des Modell-Cache erkennen können, bevor er das Modell aus dem Cache vorbereitet.

Eine Möglichkeit dafür besteht darin, dass der Treiber eine Zuordnung vom Token zu einem kryptografischen Hash des Modell-Cache verwaltet. Der Treiber kann das Token und den Hash seines Modell-Cache speichern, wenn er die Kompilierung im Cache speichert. Der Treiber prüft beim Abrufen der Kompilierung aus dem Cache den neuen Hash des Modell-Cache mit dem aufgezeichneten Token und Hash-Paar. Diese Zuordnung sollte auch nach einem Neustart des Systems bestehen bleiben. Der Treiber kann den Android-Schlüsselspeicherdienst, die Dienstprogrammbibliothek in framework/ml/nn/driver/cache oder eine andere geeignete Methode verwenden, um einen Zuordnungsmanager zu implementieren. Nach einer Treiberaktualisierung 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 Hashwert vor dem Speichern in der Datei berechnen und den neuen Hash nach dem Kopieren des Dateiinhalts in einen internen Zwischenspeicher berechnen.

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

Erweiterte Anwendungsfälle

In bestimmten erweiterten Anwendungsfällen benötigt ein Treiber nach dem Kompilierungsaufruf Zugriff auf den Cache-Inhalt (Lesen oder Schreiben). Beispiele für Anwendungsfälle:

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

Damit der Treiber nach dem Kompilierungsaufruf auf den Cache-Inhalt zugreifen kann (Lese- oder Schreibzugriff), muss Folgendes beachtet werden:

  • Dupliziert die Datei-Handles während des Aufrufs von prepareModel_1_2 oder prepareModelFromCache und liest bzw. aktualisiert den Cache-Inhalt zu einem späteren Zeitpunkt.
  • Implementiert eine Dateisperrlogik außerhalb des normalen Kompilierungsaufrufs, um einen Schreibvorgang zu verhindern, der gleichzeitig mit einem Lese- oder einem anderen Schreibvorgang ausgeführt wird.

Caching-Engine implementieren

Zusätzlich zur NN HAL 1.2-Schnittstelle für das Kompilierungs-Caching finden Sie im Verzeichnis frameworks/ml/nn/driver/cache auch eine Caching-Dienstprogrammbibliothek. Das Unterverzeichnis nnCache enthält nichtflüchtigen Speichercode für den Treiber, um das Kompilierungs-Caching zu implementieren, ohne die NNAPI-Caching-Funktionen zu verwenden. Diese Form des Kompilierungs-Cachings kann mit jeder Version von NN HAL implementiert werden. Wenn der Treiber ein von der HAL-Schnittstelle getrenntes Caching implementieren möchte, ist der Treiber dafür verantwortlich, im Cache gespeicherte Artefakte freizugeben, wenn sie nicht mehr benötigt werden.