Kompilierungs-Caching

Ab Android 10 bietet die Neural Networks API (NNAPI) Funktionen zum Caching von Kompilierungsartefakten, wodurch die für die Kompilierung beim Starten einer App benötigte Zeit reduziert wird. Dank dieser Caching-Funktion müssen die zwischengespeicherten Dateien nicht vom Treiber verwaltet oder bereinigt werden. 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, der Ausführungspräferenz und des Partitionierungsergebnisses nach den Cachedateien und findet sie.
  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.

Angegebene 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 Cache-Informationen in die Cachedateien 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 gibt das Modell mit prepareModel_1_2 an den Treiber weiter.
  4. Der Treiber kompiliert das Modell und gibt es zurück.

Cache-Informationen

Die Informationen zum Caching, die einem Treiber zur Verfügung gestellt werden, bestehen aus einem Token und Cachedatei-Handles.

Token

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

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

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

  • Datencache: Zum Caching konstanter Daten, einschließlich vorverarbeiteter und transformierter Tensor-Puffer. Eine Änderung am Datencache sollte nicht zu schlimmeren Auswirkungen führen als zur Generierung fehlerhafter Ausgabewerte bei der Ausführung.
  • Modellcache: Zum Caching sicherheitsrelevanter Daten wie kompilierten ausführbaren Maschinencodes im nativen Binärformat des Geräts. 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 er das Modell aus dem Cache vorbereitet. Weitere Informationen finden Sie unter Sicherheit.

Der Treiber muss festlegen, wie Cache-Informationen zwischen den beiden Cachedateitypen verteilt werden, und mit getNumberOfCacheFilesNeeded angeben, wie viele Cachedateien für jeden Typ benötigt werden.

Die NNAPI-Laufzeit öffnet Cachedatei-Handle immer mit Lese- und Schreibberechtigung.

Sicherheit

Beim Kompilierungs-Caching kann der Modellcache sicherheitsrelevante Daten wie kompilierten ausführbaren Maschinencode im nativen Binärformat des Geräts enthalten. Wenn der Modellcache nicht ordnungsgemäß geschützt ist, kann eine Änderung daran das Ausführungsverhalten des Treibers beeinträchtigen. 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 den Eigenschaften des Geräts kann dies ein Sicherheitsproblem darstellen. Daher muss der Treiber potenzielle Beschädigungen des Modell-Caches erkennen können, bevor das Modell aus dem Cache bereitgestellt wird.

Eine Möglichkeit besteht darin, dass der Treiber eine Zuordnung vom Token zu einem kryptografischen Hash des Modellcaches beibehält. Der Treiber kann das Token und den Hash seines Modell-Cache speichern, wenn er die Kompilierung im Cache speichert. Der Treiber vergleicht den neuen Hashwert des Modell-Caches mit dem aufgezeichneten Token- und Hash-Paar, wenn er die Kompilierung aus dem Cache abruft. Diese Zuordnung sollte auch nach einem Systemneustart 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 dem Treiberupdate sollte dieser Zuordnungsmanager neu initialisiert werden, um zu verhindern, dass Cachedateien 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 berechnen, bevor er in die Datei gespeichert wird, und den neuen Hashwert berechnen, nachdem der Dateiinhalt in einen internen Puffer kopiert wurde.

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.

Für den Zugriff auf den Cache-Inhalt (Lesen oder Schreiben) nach dem Kompilierungsaufruf muss der Treiber folgende Voraussetzungen erfüllen:

  • 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 die Logik zur Dateisperre außerhalb des normalen Kompilierungsaufrufs, um zu verhindern, dass ein Schreibvorgang 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 der NN HAL implementiert werden. Wenn der Treiber das Caching unabhängig von der HAL-Schnittstelle implementiert, ist er dafür verantwortlich, die im Cache gespeicherten Artefakte freizugeben, wenn sie nicht mehr benötigt werden.