컴파일 캐싱

Android 10에서는 NNAPI(Neural Networks API)가 컴파일 아티팩트의 캐싱을 지원하기 위한 기능을 제공하여 앱 시작 시 컴파일에 사용되는 시간을 줄여줍니다. 캐싱 기능을 사용하면 드라이버에서 캐시된 파일을 관리하거나 정리할 필요가 없습니다. 이는 NN HAL 1.2로 구현 가능한 선택적 기능입니다. 이 함수에 관한 자세한 내용은 ANeuralNetworksCompilation_setCaching을 참고하세요.

또한 드라이버는 NNAPI와 상관없는 컴파일 캐싱을 구현할 수 있습니다. 이는 NNAPI NDK 및 HAL 캐싱 기능의 사용 여부와 상관없이 구현할 수 있습니다. AOSP는 낮은 수준의 유틸리티 라이브러리(캐싱 엔진)를 제공합니다. 자세한 내용은 캐싱 엔진 구현을 참조하세요.

워크플로 개요

이 섹션에서는 구현된 컴파일 캐싱 기능에 관련된 일반적인 워크플로를 설명합니다.

제공된 캐시 정보 및 캐시 적중

  1. 앱에서는 모델의 고유한 캐싱 디렉터리와 체크섬을 전달합니다.
  2. NNAPI 런타임은 체크섬, 실행 환경설정, 파티션 분할 결과를 기준으로 캐시 파일을 찾고 파일을 찾습니다.
  3. NNAPI가 캐시 파일을 열고 prepareModelFromCache가 포함된 드라이버에 핸들을 전달합니다.
  4. 드라이버가 캐시 파일에서 바로 모델을 준비하고 준비된 모델을 반환합니다.

제공된 캐시 정보 및 캐시 부적중

  1. 앱에서 모델의 고유한 체크섬과 캐싱 디렉터리를 전달합니다.
  2. NNAPI 런타임은 체크섬, 실행 환경설정, 파티션 분할 결과를 기준으로 캐싱 파일을 찾고 캐시 파일을 찾지 않습니다.
  3. NNAPI에서 체크섬, 실행 환경설정, 파티션 분할을 기준으로 빈 캐시 파일을 생성하고 캐시 파일을 연 다음 prepareModel_1_2를 포함하는 드라이버에 핸들과 모델을 전달합니다.
  4. 드라이버가 모델을 컴파일하고 캐시 파일에 캐싱 정보를 작성하고 준비된 모델을 반환합니다.

캐시 정보가 제공되지 않음

  1. 어떠한 캐싱 정보도 제공되지 않은 상태에서 앱이 컴파일을 호출합니다.
  2. 앱이 캐싱과 관련된 어떤 것도 전달하지 않습니다.
  3. NNAPI 런타임이 prepareModel_1_2가 포함된 드라이버에 모델을 전달합니다.
  4. 드라이버가 모델을 컴파일하고 준비된 모델을 반환합니다.

캐시 정보

드라이버에 제공되는 캐싱 정보는 토큰과 캐시 파일 핸들로 구성됩니다.

토큰

토큰은 준비된 모델을 식별하는 길이 Constant::BYTE_SIZE_OF_CACHE_TOKEN의 캐싱 토큰입니다. prepareModel_1_2로 캐시 파일을 저장하고 prepareModelFromCache로 준비된 모델을 가져오면 동일한 토큰이 제공됩니다. 드라이버의 클라이언트는 충돌 비율이 낮은 토큰을 선택해야 합니다. 드라이버는 토큰 충돌을 감지할 수 없습니다. 충돌 시 실행이 실패하거나 잘못된 출력 값을 생산하는 정상 실행으로 이어집니다.

캐시 파일 핸들(두 가지 유형의 캐시 파일)

캐시 파일에는 데이터 캐시모델 캐시라는 두 가지 유형이 있습니다.

  • 데이터 캐시: 사전 처리 및 변환된 텐서 버퍼를 포함하는 상수 데이터를 캐시하는 데 사용됩니다. 데이터 캐시를 수정할 경우 발생할 수 있는 최악의 결과는 실행 시간에 잘못된 출력 값이 생성되는 것입니다.
  • 모델 캐시: 컴파일된 실행 가능한 기계어 코드(기기의 네이티브 바이너리 형식)와 같이 보안에 민감한 데이터를 캐시하는 데 사용됩니다. 모델 캐시를 수정하면 드라이버의 실행 동작에 영향을 미칠 수 있으며 악의적인 클라이언트에서 이를 악용하여 부여된 권한을 초과하는 작업을 실행할 수 있습니다. 따라서 드라이버는 캐시의 모델을 준비하기 전에 모델 캐시가 손상되었는지 확인해야 합니다. 자세한 내용은 보안을 참조하세요.

드라이버는 캐시 정보가 두 유형의 캐시 파일 간에 분산되는 방식을 결정하고 getNumberOfCacheFilesNeeded를 포함하는 각 유형에 몇 개의 캐시 파일이 필요한지 보고해야 합니다.

NNAPI 런타임은 항상 읽기 및 쓰기 권한을 둘 다 사용하여 캐시 파일 핸들을 엽니다.

보안

컴파일 캐싱에서는 모델 캐시에 컴파일된 실행 가능한 기계어 코드(기기의 네이티브 바이너리 형식)와 같이 보안에 민감한 데이터가 포함될 수 있습니다. 제대로 보호되지 않은 경우 모델 캐시 수정이 드라이버의 실행 동작에 영향을 미칠 수 있습니다. 캐시 콘텐츠는 앱 디렉터리에 저장되므로 클라이언트에서 캐시 파일을 수정할 수 있습니다. 버그가 있는 클라이언트는 실수로 캐시를 손상시킬 수 있으며 악의적인 클라이언트에서 이를 악용하여 기기에서 인증되지 않은 코드를 실행할 수 있습니다. 이는 기기 특성에 따라 보안 문제일 수도 있습니다. 따라서 드라이버는 캐시의 모델을 준비하기 전에 잠재적인 모델 캐시 손상을 감지할 수 있어야 합니다.

이를 위한 하나의 방법은 드라이버가 모델 캐시의 암호화 해시 대상 토큰의 맵을 유지하는 것입니다. 드라이버는 컴파일을 캐시에 저장할 때 모델 캐시의 토큰과 해시를 저장할 수 있습니다. 드라이버는 캐시에서 컴파일을 가져올 때 기록된 토큰과 해시 쌍으로 모델 캐시의 새 해시를 확인합니다. 이 매핑은 시스템 재부팅 내내 지속되어야 합니다. 드라이버는 Android 키 저장소 서비스, framework/ml/nn/driver/cache의 유틸리티 라이브러리 또는 기타 모든 적절한 메커니즘을 사용하여 매핑 관리자를 구현할 수 있습니다. 드라이버가 업데이트되면 캐시 파일을 이전 버전에서 준비하지 않도록 이 매핑 관리자가 다시 초기화되어야 합니다.

TOCTOU(time-of-check to time-of-use) 공격을 방지하려면 드라이버가 파일에 저장하기 전에 기록된 해시를 계산해야 하며 파일 콘텐츠를 내부 버퍼에 복사한 후에는 새 해시를 계산해야 합니다.

다음의 샘플 코드는 이 논리를 구현하는 방법을 보여줍니다.

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

고급 사용 사례

특정 고급 사용 사례에서는 컴파일 호출 이후에 드라이버에서 캐시 콘텐츠에 대한 액세스 권한(읽기 또는 쓰기)을 요구합니다. 사용 사례의 예는 다음과 같습니다.

  • Just-in-time 컴파일: 최초 실행까지 컴파일이 지연됩니다.
  • 다단계 컴파일: 처음에는 빠른 컴파일이 실행되며 나중에는 사용 빈도에 따라 최적화된 컴파일이 선택적으로 실행됩니다.

컴파일 호출 후에 캐시 콘텐츠에 액세스(읽기 또는 쓰기)하려면 드라이버가 다음을 실행해야 합니다.

  • prepareModel_1_2 또는 prepareModelFromCache 호출 도중 파일 핸들을 복제하고 나중에는 캐시 콘텐츠를 읽고 업데이트합니다.
  • 일반 컴파일 호출 외의 파일 잠금 논리를 구현하여 쓰기가 읽기 또는 다른 쓰기와 동시에 발생하지 않도록 합니다.

캐싱 엔진 구현

NN HAL 1.2 컴파일 캐싱 인터페이스 외에 frameworks/ml/nn/driver/cache 디렉터리에서 캐싱 유틸리티 라이브러리도 찾을 수 있습니다. nnCache 하위 디렉터리에는 드라이버에서 NNAPI 캐싱 기능을 사용하지 않고 컴파일 캐싱을 구현하도록 하기 위한 영구 저장소 코드가 포함되어 있습니다. 이러한 형식의 컴파일 캐싱은 NN HAL의 모든 버전으로 구현할 수 있습니다. 드라이버에서 HAL 인터페이스에서 연결 해제된 캐싱을 구현하기로 한 경우 드라이버는 더 이상 필요하지 않은 캐시된 아티팩트를 해제해야 합니다.