Store VkPipelineCache objects in PipelineCache

Implement PipelineCache, a new file-backed store for VkPipelineCache
objects. Migrate VkPipelineCache objects away from ShaderCache and store
them in PipelineCache instead.

Bug: 269117286
Flag: com.android.graphics.hwui.flags.separate_pipeline_cache
Test: adb root && adb shell aflags enable com.android.graphics.hwui.flags.separate_pipeline_cache && adb reboot
Test: atest hwui_unit_tests -- --test-arg com.android.tradefed.testtype.GTest:native-test-flag:"--renderer=skiavk"
Test: boot to home, run settings app
Test: adb logcat | rg HWUI -C 10
Test: adb shell perfetto --time 10s --out /tmp/perfetto.trace view
Change-Id: Icc2fc14d4220a34269e5999491b64662a16ef3fd
diff --git a/graphics/java/android/graphics/HardwareRenderer.java b/graphics/java/android/graphics/HardwareRenderer.java
index 4f8044e..15d12e8 100644
--- a/graphics/java/android/graphics/HardwareRenderer.java
+++ b/graphics/java/android/graphics/HardwareRenderer.java
@@ -179,8 +179,9 @@
     /**
      * Name of the file that holds the shaders cache.
      */
-    private static final String CACHE_PATH_SHADERS = "com.android.opengl.shaders_cache";
-    private static final String CACHE_PATH_SKIASHADERS = "com.android.skia.shaders_cache";
+    private static final String CACHE_PATH_OPENGL_SHADERS = "com.android.opengl.shaders_cache";
+    private static final String CACHE_PATH_SKIA_SHADERS = "com.android.skia.shaders_cache";
+    private static final String CACHE_PATH_SKIA_PIPELINES = "com.android.skia.pipelines_cache";
 
     private static int sDensityDpi = 0;
 
@@ -1292,8 +1293,10 @@
      * @hide
      */
     public static void setupDiskCache(File cacheDir) {
-        setupShadersDiskCache(new File(cacheDir, CACHE_PATH_SHADERS).getAbsolutePath(),
-                new File(cacheDir, CACHE_PATH_SKIASHADERS).getAbsolutePath());
+        setupPersistentGraphicsCache(
+            new File(cacheDir, CACHE_PATH_OPENGL_SHADERS).getAbsolutePath(),
+            new File(cacheDir, CACHE_PATH_SKIA_SHADERS).getAbsolutePath(),
+            new File(cacheDir, CACHE_PATH_SKIA_PIPELINES).getAbsolutePath());
     }
 
     /** @hide */
@@ -1587,7 +1590,8 @@
     protected static native boolean isWebViewOverlaysEnabled();
 
     /** @hide */
-    protected static native void setupShadersDiskCache(String cacheFile, String skiaCacheFile);
+    protected static native void setupPersistentGraphicsCache(
+            String openglShaderCachePath, String skiaShaderCachePath, String skiaPipelineCachePath);
 
     private static native void nRotateProcessStatsBuffer();
 
diff --git a/libs/hwui/Android.bp b/libs/hwui/Android.bp
index 2c11c84..0780ec2 100644
--- a/libs/hwui/Android.bp
+++ b/libs/hwui/Android.bp
@@ -664,6 +664,8 @@
                 "pipeline/skia/ATraceMemoryDump.cpp",
                 "pipeline/skia/GLFunctorDrawable.cpp",
                 "pipeline/skia/LayerDrawable.cpp",
+                "pipeline/skia/PersistentGraphicsCache.cpp",
+                "pipeline/skia/PipelineCache.cpp",
                 "pipeline/skia/ShaderCache.cpp",
                 "pipeline/skia/SkiaGpuPipeline.cpp",
                 "pipeline/skia/SkiaMemoryTracer.cpp",
@@ -814,6 +816,8 @@
         "tests/unit/MatrixTests.cpp",
         "tests/unit/OpBufferTests.cpp",
         "tests/unit/PathInterpolatorTests.cpp",
+        "tests/unit/PersistentGraphicsCacheTests.cpp",
+        "tests/unit/PipelineCacheTests.cpp",
         "tests/unit/RenderEffectCapabilityQueryTests.cpp",
         "tests/unit/RenderNodeDrawableTests.cpp",
         "tests/unit/RenderNodeTests.cpp",
diff --git a/libs/hwui/jni/android_graphics_HardwareRenderer.cpp b/libs/hwui/jni/android_graphics_HardwareRenderer.cpp
index 9c83c59..6cc3d66 100644
--- a/libs/hwui/jni/android_graphics_HardwareRenderer.cpp
+++ b/libs/hwui/jni/android_graphics_HardwareRenderer.cpp
@@ -43,6 +43,7 @@
 #include <media/NdkImageReader.h>
 #include <nativehelper/JNIPlatformHelp.h>
 #ifdef __ANDROID__
+#include <pipeline/skia/PersistentGraphicsCache.h>
 #include <pipeline/skia/ShaderCache.h>
 #include <private/EGL/cache.h>
 #endif
@@ -945,19 +946,25 @@
 }
 
 // ----------------------------------------------------------------------------
-// Shaders
+// Persistent graphics cache
 // ----------------------------------------------------------------------------
 
-static void android_view_ThreadedRenderer_setupShadersDiskCache(JNIEnv* env, jobject clazz,
-        jstring diskCachePath, jstring skiaDiskCachePath) {
+static void android_view_ThreadedRenderer_setupPersistentGraphicsCache(
+        JNIEnv* env, jobject clazz, jstring openglShaderCachePath, jstring skiaShaderCachePath,
+        jstring skiaPipelineCachePath) {
 #ifdef __ANDROID__
-    const char* cacheArray = env->GetStringUTFChars(diskCachePath, NULL);
-    android::egl_set_cache_filename(cacheArray);
-    env->ReleaseStringUTFChars(diskCachePath, cacheArray);
+    const char* openglShaderCachePathArray = env->GetStringUTFChars(openglShaderCachePath, NULL);
+    android::egl_set_cache_filename(openglShaderCachePathArray);
+    env->ReleaseStringUTFChars(openglShaderCachePath, openglShaderCachePathArray);
 
-    const char* skiaCacheArray = env->GetStringUTFChars(skiaDiskCachePath, NULL);
-    uirenderer::skiapipeline::ShaderCache::get().setFilename(skiaCacheArray);
-    env->ReleaseStringUTFChars(skiaDiskCachePath, skiaCacheArray);
+    const char* skiaShaderCachePathArray = env->GetStringUTFChars(skiaShaderCachePath, NULL);
+    uirenderer::skiapipeline::ShaderCache::get().setFilename(skiaShaderCachePathArray);
+    env->ReleaseStringUTFChars(skiaShaderCachePath, skiaShaderCachePathArray);
+
+    const char* skiaPipelineCachePathArray = env->GetStringUTFChars(skiaPipelineCachePath, NULL);
+    uirenderer::skiapipeline::PersistentGraphicsCache::get().initPipelineCache(
+            skiaPipelineCachePathArray);
+    env->ReleaseStringUTFChars(skiaPipelineCachePath, skiaPipelineCachePathArray);
 #endif
 }
 
@@ -1025,8 +1032,9 @@
          (void*)android_view_ThreadedRenderer_dumpProfileInfo},
         {"nDumpGlobalProfileInfo", "(Ljava/io/FileDescriptor;I)V",
          (void*)android_view_ThreadedRenderer_dumpGlobalProfileInfo},
-        {"setupShadersDiskCache", "(Ljava/lang/String;Ljava/lang/String;)V",
-         (void*)android_view_ThreadedRenderer_setupShadersDiskCache},
+        {"setupPersistentGraphicsCache",
+         "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V",
+         (void*)android_view_ThreadedRenderer_setupPersistentGraphicsCache},
         {"nAddRenderNode", "(JJZ)V", (void*)android_view_ThreadedRenderer_addRenderNode},
         {"nRemoveRenderNode", "(JJ)V", (void*)android_view_ThreadedRenderer_removeRenderNode},
         {"nDrawRenderNode", "(JJ)V", (void*)android_view_ThreadedRendererd_drawRenderNode},
diff --git a/libs/hwui/pipeline/skia/PersistentGraphicsCache.cpp b/libs/hwui/pipeline/skia/PersistentGraphicsCache.cpp
new file mode 100644
index 0000000..c0d00eb
--- /dev/null
+++ b/libs/hwui/pipeline/skia/PersistentGraphicsCache.cpp
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "PersistentGraphicsCache.h"
+
+#include <SkData.h>
+#include <SkRefCnt.h>
+#include <SkString.h>
+#include <ganesh/GrDirectContext.h>
+#include <log/log.h>
+
+#include <cstddef>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "Properties.h"
+#include "ShaderCache.h"
+
+#ifdef __linux__
+#include <com_android_graphics_hwui_flags.h>
+namespace hwui_flags = com::android::graphics::hwui::flags;
+#else   // __linux__
+namespace hwui_flags {
+constexpr bool separate_pipeline_cache() {
+    return false;
+}
+}  // namespace hwui_flags
+#endif  // __linux__
+
+namespace {
+
+constexpr size_t kMaxPipelineSizeBytes = 2 * 1024 * 1024;
+
+}  // namespace
+
+namespace android {
+namespace uirenderer {
+namespace skiapipeline {
+
+PersistentGraphicsCache& PersistentGraphicsCache::get() {
+    static PersistentGraphicsCache cache;
+    return cache;
+}
+
+void PersistentGraphicsCache::initPipelineCache(std::string path,
+                                                useconds_t writeThrottleInterval) {
+    if (!hwui_flags::separate_pipeline_cache()) {
+        return;
+    }
+
+    mPipelineCache = std::make_unique<PipelineCache>(std::move(path), writeThrottleInterval);
+}
+
+void PersistentGraphicsCache::onVkFrameFlushed(GrDirectContext* context) {
+    class RealGrDirectContext : public GrDirectContextWrapper {
+    private:
+        GrDirectContext* mContext;
+
+    public:
+        RealGrDirectContext(GrDirectContext* context) : mContext(context) {}
+
+        bool canDetectNewVkPipelineCacheData() const override {
+            return mContext->canDetectNewVkPipelineCacheData();
+        }
+
+        bool hasNewVkPipelineCacheData() const override {
+            return mContext->hasNewVkPipelineCacheData();
+        }
+
+        void storeVkPipelineCacheData(size_t maxSize) override {
+            return mContext->storeVkPipelineCacheData(maxSize);
+        }
+
+        GrDirectContext* unwrap() const override { return mContext; }
+    };
+
+    RealGrDirectContext wrapper(context);
+    onVkFrameFlushed(&wrapper);
+}
+
+void PersistentGraphicsCache::onVkFrameFlushed(GrDirectContextWrapper* context) {
+    if (!hwui_flags::separate_pipeline_cache()) {
+        ShaderCache::get().onVkFrameFlushed(context->unwrap());
+        return;
+    }
+
+    mCanDetectNewVkPipelineCacheData = context->canDetectNewVkPipelineCacheData();
+    if (context->hasNewVkPipelineCacheData()) {
+        context->storeVkPipelineCacheData(kMaxPipelineSizeBytes);
+    }
+}
+
+sk_sp<SkData> PersistentGraphicsCache::load(const SkData& key) {
+    if (!hwui_flags::separate_pipeline_cache()) {
+        return ShaderCache::get().load(key);
+    }
+
+    if (mPipelineCache == nullptr) {
+        LOG_ALWAYS_FATAL(
+                "PersistentGraphicsCache::load: pipeline cache path was not initialized, aborting "
+                "load");
+        return nullptr;
+    }
+
+    auto data = mPipelineCache->tryLoad(key);
+    if (data != nullptr) {
+        return data;
+    }
+
+    return ShaderCache::get().load(key);
+}
+
+void PersistentGraphicsCache::store(const SkData& key, const SkData& data,
+                                    const SkString& description) {
+    if (!hwui_flags::separate_pipeline_cache()) {
+        ShaderCache::get().store(key, data, description);
+        return;
+    }
+
+    if (mPipelineCache == nullptr) {
+        LOG_ALWAYS_FATAL(
+                "PersistentGraphicsCache::store: pipeline cache path was not initialized, aborting "
+                "store");
+        return;
+    }
+
+    if (mPipelineCache->canStore(description)) {
+        if (mCanDetectNewVkPipelineCacheData) {
+            mPipelineCache->store(key, data);
+        } else if (mLastPipelineCacheSize != data.size()) {
+            mPipelineCache->store(key, data);
+            mLastPipelineCacheSize = data.size();
+        }
+        return;
+    }
+
+    ShaderCache::get().store(key, data, description);
+}
+
+}  // namespace skiapipeline
+}  // namespace uirenderer
+}  // namespace android
diff --git a/libs/hwui/pipeline/skia/PersistentGraphicsCache.h b/libs/hwui/pipeline/skia/PersistentGraphicsCache.h
new file mode 100644
index 0000000..385ce33
--- /dev/null
+++ b/libs/hwui/pipeline/skia/PersistentGraphicsCache.h
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <ganesh/GrContextOptions.h>
+#include <ganesh/GrDirectContext.h>
+#include <sys/types.h>
+
+#include <cstddef>
+#include <memory>
+#include <string>
+
+#include "PipelineCache.h"
+
+namespace android {
+namespace uirenderer {
+namespace skiapipeline {
+
+// Delegate persistent cache operations to either the pipeline cache or the shader cache as
+// appropriate
+class PersistentGraphicsCache : public GrContextOptions::PersistentCache {
+    static constexpr useconds_t kDefaultWriteThrottleInterval = 4 * 1000 * 1000;
+
+public:
+    static PersistentGraphicsCache& get();
+
+    void initPipelineCache(std::string path,
+                           useconds_t writeThrottleInterval = kDefaultWriteThrottleInterval);
+    void onVkFrameFlushed(GrDirectContext* context);
+
+    sk_sp<SkData> load(const SkData& key) override;
+    void store(const SkData& key, const SkData& data, const SkString& description) override;
+
+private:
+    std::unique_ptr<PipelineCache> mPipelineCache;
+
+    // Workarounds for devices without VK_EXT_pipeline_creation_cache_control
+    bool mCanDetectNewVkPipelineCacheData;
+    size_t mLastPipelineCacheSize;
+
+    // Unit test infrastructure
+    class GrDirectContextWrapper {
+    public:
+        virtual ~GrDirectContextWrapper() = default;
+
+        virtual bool canDetectNewVkPipelineCacheData() const = 0;
+        virtual bool hasNewVkPipelineCacheData() const = 0;
+        virtual void storeVkPipelineCacheData(size_t maxSize) = 0;
+
+        virtual GrDirectContext* unwrap() const = 0;
+    };
+
+    void onVkFrameFlushed(GrDirectContextWrapper* context);
+
+    friend class PersistentGraphicsCacheTestUtils;
+};
+
+}  // namespace skiapipeline
+}  // namespace uirenderer
+}  // namespace android
diff --git a/libs/hwui/pipeline/skia/PipelineCache.cpp b/libs/hwui/pipeline/skia/PipelineCache.cpp
new file mode 100644
index 0000000..3d2021d
--- /dev/null
+++ b/libs/hwui/pipeline/skia/PipelineCache.cpp
@@ -0,0 +1,299 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "PipelineCache.h"
+
+#include <SkData.h>
+#include <SkRefCnt.h>
+#include <SkString.h>
+#include <android-base/unique_fd.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <log/log.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+#include <utils/Trace.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <fstream>
+#include <mutex>
+#include <string>
+#include <utility>
+#include <vector>
+
+namespace {
+
+using key_size_t = uint32_t;
+
+void releaseProc(const void* ptr, void* context) {
+    const auto size = reinterpret_cast<size_t>(context);
+    release(Memory{.data = const_cast<void*>(ptr), .size = size});
+}
+
+struct PipelineCacheData {
+    key_size_t keySize;
+    sk_sp<SkData> key;
+    sk_sp<SkData> data;
+
+    struct LoadResult {
+        enum Outcome {
+            Success,
+            CouldNotAcquire,
+            ZeroSize,
+            NoKeySize,
+            NoKey,
+        };
+        Outcome outcome;
+        AcquireResult acquireResult;
+    };
+
+    static LoadResult load(const std::string& path, PipelineCacheData& cache) {
+        Memory memory;
+        auto result = acquire(path, memory);
+        if (result.outcome != AcquireResult::Success) {
+            return LoadResult{.outcome = LoadResult::CouldNotAcquire, .acquireResult = result};
+        }
+
+        if (memory.size == 0) {
+            release(memory);
+            return LoadResult{.outcome = LoadResult::ZeroSize};
+        }
+
+        if (memory.size < sizeof(key_size_t)) {
+            release(memory);
+            return LoadResult{.outcome = LoadResult::NoKeySize};
+        }
+        memcpy(&cache.keySize, memory.data, sizeof(key_size_t));
+
+        if (memory.size < (sizeof(key_size_t) + cache.keySize)) {
+            release(memory);
+            return LoadResult{.outcome = LoadResult::NoKey};
+        }
+        cache.key = SkData::MakeWithCopy(static_cast<uint8_t*>(memory.data) + sizeof(key_size_t),
+                                         cache.keySize);
+
+        auto dataSize = memory.size - sizeof(key_size_t) - cache.keySize;
+        cache.data = SkData::MakeWithProc(
+                static_cast<uint8_t*>(memory.data) + sizeof(key_size_t) + cache.keySize, dataSize,
+                releaseProc, reinterpret_cast<void*>(memory.size));
+
+        return LoadResult{.outcome = LoadResult::Success};
+    }
+};
+
+void logLoadWarning(PipelineCacheData::LoadResult result, const char* message) {
+    if (result.outcome == PipelineCacheData::LoadResult::CouldNotAcquire) {
+        // Missing file is a normal case (cache was never written - there is no failure)
+        if ((result.acquireResult.outcome == AcquireResult::OpenFailed) &&
+            (result.acquireResult.errnoValue == ENOENT)) {
+            return;
+        }
+
+        ALOGW("%s; acquire outcome=%d, errnoValue=%d", message, result.acquireResult.outcome,
+              result.acquireResult.errnoValue);
+        return;
+    }
+
+    ALOGW("%s; outcome=%d", message, result.outcome);
+}
+
+}  // namespace
+
+AcquireResult acquire(const std::string& path, Memory& memory) {
+    android::base::unique_fd fd(open(path.c_str(), O_RDONLY));
+    if (fd.get() == -1) {
+        return AcquireResult{.outcome = AcquireResult::OpenFailed, .errnoValue = errno};
+    }
+
+    struct stat stat;
+    auto result = fstat(fd.get(), &stat);
+    if (result == -1) {
+        return AcquireResult{.outcome = AcquireResult::FstatFailed, .errnoValue = errno};
+    }
+    if (stat.st_size == 0) {
+        return AcquireResult{.outcome = AcquireResult::CannotMmapZeroSizeFile};
+    }
+
+    auto data = mmap(nullptr, stat.st_size, PROT_READ, MAP_SHARED, fd.get(), 0);
+    if (data == reinterpret_cast<void*>(-1)) {
+        return AcquireResult{.outcome = AcquireResult::MmapFailed, .errnoValue = errno};
+    }
+
+    memory.data = data;
+    memory.size = stat.st_size;
+
+    return AcquireResult{.outcome = AcquireResult::Success};
+}
+
+ReleaseResult release(Memory memory) {
+    auto result = munmap(memory.data, memory.size);
+    if (result == -1) {
+        return ReleaseResult{.outcome = ReleaseResult::MunmapFailed, .errnoValue = errno};
+    }
+
+    return ReleaseResult{.outcome = ReleaseResult::Success};
+}
+
+PipelineCacheStore::PipelineCacheStore(useconds_t writeThrottleInterval)
+        : mWriteThrottleInterval(writeThrottleInterval)
+        , mMutex()
+        , mConditionVariable()
+        , mStoreRequest()
+        , mExit(false)
+        , mThread(&PipelineCacheStore::runThread, this) {}
+
+PipelineCacheStore::~PipelineCacheStore() {
+    mExit = true;
+    mConditionVariable.notify_one();
+    mThread.join();
+}
+
+void PipelineCacheStore::runThread() {
+    while (true) {
+        {
+            std::unique_lock<std::mutex> lock(mMutex);
+            mConditionVariable.wait(lock, [this] { return mExit || mStoreRequest.has_value(); });
+        }
+
+        if (mExit) {
+            return;
+        }
+
+        {
+            ATRACE_NAME("PipelineCacheStore::runThread (delay to throttle cache requests)");
+            // Frequent sequential cache writes will be written at most once per interval to reduce
+            // I/O activity.
+            usleep(mWriteThrottleInterval);
+        }
+
+        StoreRequest storeRequest;
+        {
+            std::lock_guard<std::mutex> lock(mMutex);
+
+            storeRequest = std::move(mStoreRequest.value());
+            mStoreRequest.reset();
+        }
+
+        {
+            ATRACE_NAME("PipelineCacheStore::runThread (write to file cache)");
+
+            android::base::unique_fd fd(creat(storeRequest.path.c_str(), S_IRUSR | S_IWUSR));
+            if (fd.get() == -1) {
+                ALOGE("PipelineCacheStore::runThread: could not open pipeline cache file (errno = "
+                      "%d)",
+                      errno);
+                continue;
+            }
+
+            auto written = write(fd.get(), storeRequest.data.data(), storeRequest.data.size());
+            if (written == -1) {
+                ALOGE("PipelineCacheStore::runThread: could not write to pipeline cache file "
+                      "(errno = %d)",
+                      errno);
+                continue;
+            }
+
+            ATRACE_INT64("HWUI pipeline cache size", written);
+        }
+    }
+}
+
+void PipelineCacheStore::store(std::string path, std::vector<uint8_t> data) {
+    ATRACE_NAME("PipelineCacheStore::store (lock mutex and notify condition)");
+
+    {
+        std::lock_guard<std::mutex> lock(mMutex);
+        mStoreRequest = StoreRequest{
+                .path = std::move(path),
+                .data = std::move(data),
+        };
+    }
+
+    mConditionVariable.notify_one();
+}
+
+PipelineCache::PipelineCache(std::string storePath, useconds_t writeThrottleInterval)
+        : mStorePath(std::move(storePath))
+        , mPipelineCacheStore(writeThrottleInterval)
+        , mKey(SkData::MakeEmpty())
+        , mData(SkData::MakeEmpty()) {
+    PipelineCacheData cache;
+    auto result = PipelineCacheData::load(mStorePath, cache);
+    if (result.outcome != PipelineCacheData::LoadResult::Success) {
+        logLoadWarning(
+                result,
+                "PipelineCache::PipelineCache: could not load cache key (cache will be dropped)");
+        return;
+    }
+
+    mKey = cache.key;
+    mData = cache.data;
+}
+
+sk_sp<SkData> PipelineCache::tryLoad(const SkData& key) {
+    ATRACE_NAME("PipelineCache::tryLoad");
+
+    if (!key.equals(mKey.get())) {
+        return nullptr;
+    }
+
+    if (mData == nullptr) {
+        ALOGW("PipelineCache::tryLoad: multiple data loads, incurring a load cost on the critical "
+              "path");
+
+        PipelineCacheData cache;
+        auto result = PipelineCacheData::load(mStorePath, cache);
+        if (result.outcome != PipelineCacheData::LoadResult::Success) {
+            logLoadWarning(
+                    result,
+                    "PipelineCache::tryLoad: could not load cache key (cache will be dropped)");
+            return nullptr;
+        }
+
+        return std::move(cache.data);
+    }
+
+    return std::move(mData);
+}
+
+bool PipelineCache::canStore(const SkString& description) const {
+    return description == SkString("VkPipelineCache");
+}
+
+void PipelineCache::store(const SkData& key, const SkData& data) {
+    ATRACE_NAME("PipelineCache::store");
+
+    mKey = SkData::MakeWithCopy(key.data(), key.size());
+
+    auto dataSize = sizeof(key_size_t) + key.size() + data.size();
+    std::vector<uint8_t> pendingData(dataSize);
+    auto ptr = pendingData.data();
+
+    auto keySize = static_cast<key_size_t>(key.size());
+    memcpy(ptr, &keySize, sizeof(key_size_t));
+    ptr += sizeof(key_size_t);
+
+    memcpy(ptr, key.data(), key.size());
+    ptr += key.size();
+
+    memcpy(ptr, data.data(), data.size());
+
+    mPipelineCacheStore.store(mStorePath, std::move(pendingData));
+}
diff --git a/libs/hwui/pipeline/skia/PipelineCache.h b/libs/hwui/pipeline/skia/PipelineCache.h
new file mode 100644
index 0000000..812523b
--- /dev/null
+++ b/libs/hwui/pipeline/skia/PipelineCache.h
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <SkData.h>
+#include <SkRefCnt.h>
+#include <SkString.h>
+#include <sys/types.h>
+
+#include <atomic>
+#include <condition_variable>
+#include <cstddef>
+#include <cstdint>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <thread>
+#include <vector>
+
+struct Memory {
+    void* data;
+    size_t size;
+};
+
+struct AcquireResult {
+    enum Outcome {
+        Success,
+        OpenFailed,
+        FstatFailed,
+        CannotMmapZeroSizeFile,
+        MmapFailed,
+    };
+    Outcome outcome;
+    int errnoValue;
+};
+
+AcquireResult acquire(const std::string& path, Memory& memory);
+
+struct ReleaseResult {
+    enum Outcome {
+        Success,
+        MunmapFailed,
+    };
+    Outcome outcome;
+    int errnoValue;
+};
+
+ReleaseResult release(Memory memory);
+
+class PipelineCacheStore {
+public:
+    PipelineCacheStore(useconds_t writeThrottleInterval);
+
+    ~PipelineCacheStore();
+
+    PipelineCacheStore(const PipelineCacheStore&) = delete;
+    PipelineCacheStore& operator=(const PipelineCacheStore&) = delete;
+
+    // Address must be stable as data is accessed in background thread
+    PipelineCacheStore(PipelineCacheStore&&) = delete;
+    PipelineCacheStore& operator=(PipelineCacheStore&&) = delete;
+
+    void store(std::string path, std::vector<uint8_t> data);
+
+private:
+    void runThread();
+
+    useconds_t mWriteThrottleInterval;
+
+    std::mutex mMutex;
+    std::condition_variable mConditionVariable;
+
+    struct StoreRequest {
+        std::string path;
+        std::vector<uint8_t> data;
+    };
+    std::optional<StoreRequest> mStoreRequest;
+
+    std::atomic_bool mExit;
+    std::thread mThread;
+};
+
+class PipelineCache {
+public:
+    PipelineCache(std::string storePath, useconds_t writeThrottleInterval);
+
+    sk_sp<SkData> tryLoad(const SkData& key);
+    bool canStore(const SkString& description) const;
+    void store(const SkData& key, const SkData& data);
+
+private:
+    std::string mStorePath;
+    PipelineCacheStore mPipelineCacheStore;
+
+    sk_sp<SkData> mKey;
+    sk_sp<SkData> mData;
+};
diff --git a/libs/hwui/renderthread/CacheManager.cpp b/libs/hwui/renderthread/CacheManager.cpp
index 2771780..0c2dfd0 100644
--- a/libs/hwui/renderthread/CacheManager.cpp
+++ b/libs/hwui/renderthread/CacheManager.cpp
@@ -32,6 +32,7 @@
 #include "RenderThread.h"
 #include "VulkanManager.h"
 #include "pipeline/skia/ATraceMemoryDump.h"
+#include "pipeline/skia/PersistentGraphicsCache.h"
 #include "pipeline/skia/ShaderCache.h"
 #include "pipeline/skia/SkiaMemoryTracer.h"
 #include "renderstate/RenderState.h"
@@ -106,9 +107,11 @@
     contextOptions->fGlyphCacheTextureMaximumBytes = mMaxGpuFontAtlasBytes;
     contextOptions->fExecutor = &sDefaultExecutor;
 
-    auto& cache = skiapipeline::ShaderCache::get();
-    cache.initShaderDiskCache(identity, size);
-    contextOptions->fPersistentCache = &cache;
+    auto& shaderCache = skiapipeline::ShaderCache::get();
+    shaderCache.initShaderDiskCache(identity, size);
+
+    auto& graphicsCache = skiapipeline::PersistentGraphicsCache::get();
+    contextOptions->fPersistentCache = &graphicsCache;
 }
 
 static GrPurgeResourceOptions toSkiaEnum(bool scratchOnly) {
diff --git a/libs/hwui/renderthread/VulkanManager.cpp b/libs/hwui/renderthread/VulkanManager.cpp
index a2ef4c6..4ea5a4e 100644
--- a/libs/hwui/renderthread/VulkanManager.cpp
+++ b/libs/hwui/renderthread/VulkanManager.cpp
@@ -36,7 +36,7 @@
 
 #include "Properties.h"
 #include "RenderThread.h"
-#include "pipeline/skia/ShaderCache.h"
+#include "pipeline/skia/PersistentGraphicsCache.h"
 #include "renderstate/RenderState.h"
 
 namespace android {
@@ -44,7 +44,7 @@
 namespace renderthread {
 
 // Not all of these are strictly required, but are all enabled if present.
-static std::array<std::string_view, 25> sEnableExtensions{
+static std::array<std::string_view, 26> sEnableExtensions{
         VK_KHR_BIND_MEMORY_2_EXTENSION_NAME,
         VK_KHR_DEDICATED_ALLOCATION_EXTENSION_NAME,
         VK_KHR_EXTERNAL_MEMORY_CAPABILITIES_EXTENSION_NAME,
@@ -70,6 +70,7 @@
         VK_EXT_DEVICE_FAULT_EXTENSION_NAME,
         VK_EXT_FRAME_BOUNDARY_EXTENSION_NAME,
         VK_ANDROID_FRAME_BOUNDARY_EXTENSION_NAME,
+        VK_EXT_PIPELINE_CREATION_CACHE_CONTROL_EXTENSION_NAME,
 };
 
 static bool shouldEnableExtension(const std::string_view& extension) {
@@ -341,6 +342,16 @@
         tailPNext = &formatFeatures->pNext;
     }
 
+    if (grExtensions.hasExtension(VK_EXT_PIPELINE_CREATION_CACHE_CONTROL_EXTENSION_NAME, 1)) {
+        VkPhysicalDevicePipelineCreationCacheControlFeatures* cacheControlFeatures =
+                new VkPhysicalDevicePipelineCreationCacheControlFeatures;
+        cacheControlFeatures->sType =
+                VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PIPELINE_CREATION_CACHE_CONTROL_FEATURES;
+        cacheControlFeatures->pNext = nullptr;
+        *tailPNext = cacheControlFeatures;
+        tailPNext = &cacheControlFeatures->pNext;
+    }
+
     VkPhysicalDeviceGlobalPriorityQueryFeaturesEXT* globalPriorityQueryFeatures =
             new VkPhysicalDeviceGlobalPriorityQueryFeaturesEXT;
     globalPriorityQueryFeatures->sType =
@@ -793,7 +804,7 @@
         mQueueWaitIdle(mGraphicsQueue);
     }
 
-    skiapipeline::ShaderCache::get().onVkFrameFlushed(context);
+    skiapipeline::PersistentGraphicsCache::get().onVkFrameFlushed(context);
 
     return drawResult;
 }
diff --git a/libs/hwui/tests/common/TestUtils.h b/libs/hwui/tests/common/TestUtils.h
index 8ab2b16..6476576 100644
--- a/libs/hwui/tests/common/TestUtils.h
+++ b/libs/hwui/tests/common/TestUtils.h
@@ -22,27 +22,52 @@
 #include <Properties.h>
 #include <Rect.h>
 #include <RenderNode.h>
-#include <hwui/Bitmap.h>
-#include <pipeline/skia/SkiaRecordingCanvas.h>
-#include <private/hwui/DrawGlInfo.h>
-#include <renderstate/RenderState.h>
-#include <renderthread/RenderThread.h>
-
 #include <SkBitmap.h>
 #include <SkColor.h>
 #include <SkFont.h>
 #include <SkImageInfo.h>
 #include <SkRefCnt.h>
-
+#include <android-base/expected.h>
+#include <errno.h>
+#include <fcntl.h>
 #include <gtest/gtest.h>
+#include <hwui/Bitmap.h>
+#include <pipeline/skia/PersistentGraphicsCache.h>
+#include <pipeline/skia/SkiaRecordingCanvas.h>
+#include <poll.h>
+#include <private/hwui/DrawGlInfo.h>
+#include <renderstate/RenderState.h>
+#include <renderthread/RenderThread.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/inotify.h>
+#include <unistd.h>
+
+#include <array>
+#include <fstream>
 #include <memory>
+#include <queue>
+#include <string>
+#include <string_view>
 #include <unordered_map>
+#include <utility>
 
 class SkCanvas;
 class SkMatrix;
 class SkPath;
 struct SkRect;
 
+#ifdef __linux__
+#include <com_android_graphics_hwui_flags.h>
+namespace hwui_flags = com::android::graphics::hwui::flags;
+#else   // __linux__
+namespace hwui_flags {
+constexpr bool separate_pipeline_cache() {
+    return false;
+}
+}  // namespace hwui_flags
+#endif  // __linux__
+
 namespace android {
 namespace uirenderer {
 
@@ -64,6 +89,10 @@
 
 #define INNER_PIPELINE_RENDERTHREAD_TEST(test_case_name, test_name)                                \
     TEST(test_case_name, test_name) {                                                              \
+        if (hwui_flags::separate_pipeline_cache()) {                                               \
+            android::uirenderer::skiapipeline::PersistentGraphicsCache::get().initPipelineCache(   \
+                    "pipeline_cache.bin");                                                         \
+        }                                                                                          \
         TestUtils::runOnRenderThread(test_case_name##_##test_name##_RenderThreadTest::doTheThing); \
     }
 
@@ -101,6 +130,151 @@
     T mOldValue;
 };
 
+/**
+ * Monitors a file for write events, allowing tests to detect whether a file has been written to or
+ * not within an expected timeframe.
+ */
+class FileEventMonitor {
+private:
+    static constexpr std::array kExpectedEvents{IN_MODIFY, IN_CLOSE_WRITE};
+
+    int mFd;
+
+public:
+    struct CreateResult {
+        enum Outcome {
+            Success,
+            InitFailed,
+            AddWatchFailed,
+        };
+        Outcome outcome;
+        int errnoValue;
+        std::unique_ptr<FileEventMonitor> monitor;
+    };
+
+    static CreateResult create(const std::string& path) {
+        auto fd = inotify_init();
+        if (fd == -1) {
+            return CreateResult{.outcome = CreateResult::InitFailed, .errnoValue = errno};
+        }
+
+        uint32_t eventMask = 0;
+        for (auto event : kExpectedEvents) {
+            eventMask |= event;
+        }
+        auto wd = inotify_add_watch(fd, path.c_str(), eventMask);
+        if (wd == -1) {
+            return CreateResult{.outcome = CreateResult::AddWatchFailed, .errnoValue = errno};
+        }
+
+        return CreateResult{
+                .outcome = CreateResult::Success,
+                .monitor = std::make_unique<FileEventMonitor>(fd),
+        };
+    }
+
+    FileEventMonitor(int fd) : mFd(fd) {}
+    ~FileEventMonitor() { close(mFd); }
+
+    FileEventMonitor(const FileEventMonitor&) = delete;
+    FileEventMonitor(FileEventMonitor&&) = delete;
+
+    FileEventMonitor& operator=(const FileEventMonitor&) = delete;
+    FileEventMonitor& operator=(FileEventMonitor&&) = delete;
+
+    enum class AwaitResult {
+        Success,
+        PollError,
+        TimedOut,
+        NotEnoughData,
+    };
+    // Note that the timeout is not a total runtime timeout but rather the timeout for each file
+    // system event, therefore the total time spent waiting may be a multiple of the timeout.
+    AwaitResult awaitWriteOrTimeout(int timeoutMs = 100) {
+        std::queue<uint32_t> expectedEvents;
+        for (auto event : kExpectedEvents) {
+            expectedEvents.push(event);
+        }
+
+        while (!expectedEvents.empty()) {
+            pollfd fd = {
+                    .fd = mFd,
+                    .events = POLLIN,
+                    .revents = 0,
+            };
+            auto result = poll(&fd, 1, timeoutMs);
+            if (result == -1) {
+                return AwaitResult::PollError;
+            }
+            if (result == 0) {
+                return AwaitResult::TimedOut;
+            }
+
+            inotify_event event;
+            auto bytes = read(mFd, &event, sizeof(event));
+            if (bytes != sizeof(event)) {
+                return AwaitResult::NotEnoughData;
+            }
+
+            if (event.mask & expectedEvents.front()) {
+                expectedEvents.pop();
+            }
+        }
+        return AwaitResult::Success;
+    }
+};
+
+#define ASSERT_SUCCESS(RESULT)                                           \
+    ASSERT_EQ(FileEventMonitor::CreateResult::Success, (RESULT).outcome) \
+            << "errno=" << (RESULT).errnoValue << " (" << strerror((RESULT).errnoValue) << ")";
+
+/**
+ * Allows tests to create a temporary file for use in the test, which is automatically removed once
+ * the test exits.
+ */
+class TestFile {
+private:
+    std::string mPath;
+
+    static std::string getTempPath(std::string filename) { return "/data/local/tmp/" + filename; }
+
+public:
+    static android::base::expected<TestFile, int> ensureExistsEmpty(std::string filename) {
+        auto path = getTempPath(std::move(filename));
+        auto fd = creat(path.c_str(), S_IRWXU | S_IRWXG | S_IRWXO);
+        if (fd == -1) {
+            return android::base::unexpected(errno);
+        }
+        close(fd);
+        return android::base::expected<TestFile, int>(std::in_place, std::move(path));
+    }
+
+    static android::base::expected<TestFile, int> ensureDoesNotExist(std::string filename) {
+        auto path = getTempPath(std::move(filename));
+        auto result = remove(path.c_str());
+        if ((result == -1) && (errno != ENOENT)) {
+            return android::base::unexpected(errno);
+        }
+        return android::base::expected<TestFile, int>(std::in_place, std::move(path));
+    }
+
+    TestFile(std::string path) : mPath(std::move(path)) {}
+    ~TestFile() { remove(mPath.c_str()); }
+
+    TestFile(const TestFile&) = delete;
+    TestFile(TestFile&&) = delete;
+
+    TestFile& operator=(const TestFile&) = delete;
+    TestFile& operator=(TestFile&&) = delete;
+
+    const std::string& path() const { return mPath; }
+
+    void write(std::string_view contents) {
+        std::ofstream stream(mPath);
+        stream << contents;
+    }
+};
+
 class TestUtils {
 public:
     class SignalingDtor {
diff --git a/libs/hwui/tests/unit/PersistentGraphicsCacheTests.cpp b/libs/hwui/tests/unit/PersistentGraphicsCacheTests.cpp
new file mode 100644
index 0000000..4c5fe64
--- /dev/null
+++ b/libs/hwui/tests/unit/PersistentGraphicsCacheTests.cpp
@@ -0,0 +1,291 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <SkData.h>
+#include <SkRefCnt.h>
+#include <SkString.h>
+#include <ganesh/GrDirectContext.h>
+#include <gtest/gtest.h>
+#include <sys/types.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <string>
+
+#include "Properties.h"
+#include "pipeline/skia/PersistentGraphicsCache.h"
+#include "tests/common/TestUtils.h"
+
+// RENDERTHREAD_TEST declares both SkiaVK and SkiaGL variants.
+#define VK_ONLY()                                                                         \
+    if (Properties::getRenderPipelineType() != RenderPipelineType::SkiaVulkan) {          \
+        GTEST_SKIP() << "This test is only applicable to RenderPipelineType::SkiaVulkan"; \
+    }
+
+#define ENSURE_FLAG_ENABLED()                                                                    \
+    if (!hwui_flags::separate_pipeline_cache()) {                                                \
+        GTEST_SKIP() << "This test is only applicable when the separate_pipeline_cache aconfig " \
+                        "flag is enabled";                                                       \
+    }
+
+using namespace android::uirenderer;
+using namespace android::uirenderer::skiapipeline;
+
+namespace {
+
+constexpr char kFilename[] = "pipeline_cache.bin";
+
+template <typename T>
+sk_sp<SkData> createData(const T value) {
+    return SkData::MakeWithCopy(&value, sizeof(value));
+}
+
+// Hardcoded Skia enum value - tests may break if Skia changes the key.
+sk_sp<SkData> getPipelineCacheKey() {
+    static sk_sp<SkData> keyData = createData<uint32_t>(1);
+    return keyData;
+}
+
+}  // namespace
+
+namespace android {
+namespace uirenderer {
+namespace skiapipeline {
+
+class PersistentGraphicsCacheTestUtils {
+private:
+    class MockGrDirectContextWrapper : public PersistentGraphicsCache::GrDirectContextWrapper {
+    private:
+        bool mCanDetectNewVkPipelineCacheData;
+        bool mHasNewVkPipelineCacheData;
+        GrDirectContext* mRealContext;
+
+    public:
+        MockGrDirectContextWrapper(bool canDetectNewVkPipelineCacheData,
+                                   bool hasNewVkPipelineCacheData, GrDirectContext* realContext)
+                : mCanDetectNewVkPipelineCacheData(canDetectNewVkPipelineCacheData)
+                , mHasNewVkPipelineCacheData(hasNewVkPipelineCacheData)
+                , mRealContext(realContext) {}
+
+        bool canDetectNewVkPipelineCacheData() const override {
+            return mCanDetectNewVkPipelineCacheData;
+        }
+
+        bool hasNewVkPipelineCacheData() const override { return mHasNewVkPipelineCacheData; }
+
+        void storeVkPipelineCacheData(size_t maxSize) override {
+            mRealContext->storeVkPipelineCacheData(maxSize);
+        }
+
+        GrDirectContext* unwrap() const override { return mRealContext; }
+    };
+
+    static void reset(PersistentGraphicsCache& cache) {
+        cache.~PersistentGraphicsCache();
+        new (&cache) PersistentGraphicsCache();
+    }
+
+public:
+    static PersistentGraphicsCache& newCache(const std::string& path,
+                                             useconds_t writeThrottleInterval = 0) {
+        auto& cache = PersistentGraphicsCache::get();
+        reset(cache);
+        cache.initPipelineCache(path, writeThrottleInterval);
+        return cache;
+    }
+
+    static void onVkFrameFlushed(PersistentGraphicsCache& cache,
+                                 bool canDetectNewVkPipelineCacheData,
+                                 bool hasNewVkPipelineCacheData, GrDirectContext* realContext) {
+        MockGrDirectContextWrapper wrapper(canDetectNewVkPipelineCacheData,
+                                           hasNewVkPipelineCacheData, realContext);
+        cache.onVkFrameFlushed(&wrapper);
+    }
+};
+
+}  // namespace skiapipeline
+}  // namespace uirenderer
+}  // namespace android
+
+TEST(PersistentGraphicsCacheTest, emptyFile_loadKey_isEmptyByDefault) {
+    // Arrange
+    ENSURE_FLAG_ENABLED();
+
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+
+    auto& cache = PersistentGraphicsCacheTestUtils::newCache(file->path());
+
+    // Act
+    auto result = cache.load(*getPipelineCacheKey());
+
+    // Assert
+    ASSERT_EQ(nullptr, result);
+}
+
+TEST(PersistentGraphicsCacheTest, store_load_returnsIdenticalData) {
+    // Arrange
+    ENSURE_FLAG_ENABLED();
+
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+
+    auto& cache = PersistentGraphicsCacheTestUtils::newCache(file->path());
+
+    auto monitorCreateResult = FileEventMonitor::create(file->path());
+    ASSERT_SUCCESS(monitorCreateResult);
+
+    uint64_t dataValue = 5;
+    auto key = createData<uint32_t>(10);
+    auto data = createData(dataValue);
+
+    // Act
+    cache.store(*key, *data, SkString("VkPipelineCache"));
+    ASSERT_EQ(FileEventMonitor::AwaitResult::Success,
+              monitorCreateResult.monitor->awaitWriteOrTimeout());
+    auto result = PersistentGraphicsCacheTestUtils::newCache(file->path()).load(*key);
+
+    // Assert
+    ASSERT_NE(nullptr, result);
+    ASSERT_EQ(sizeof(dataValue), result->size());
+    ASSERT_EQ(0, memcmp(&dataValue, result->data(), sizeof(dataValue)));
+}
+
+RENDERTHREAD_TEST(PersistentGraphicsCacheTest, hasPipelineCreationCacheControl_newCache_isStored) {
+    // Arrange
+    ENSURE_FLAG_ENABLED();
+    VK_ONLY();
+
+    auto context = renderThread.getGrContext();
+
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+
+    auto& cache = PersistentGraphicsCacheTestUtils::newCache(file->path());
+
+    auto monitorCreateResult = FileEventMonitor::create(file->path());
+    ASSERT_SUCCESS(monitorCreateResult);
+
+    // Act
+    PersistentGraphicsCacheTestUtils::onVkFrameFlushed(cache,
+                                                       /* canDetectNewVkPipelineCacheData= */ true,
+                                                       /* hasNewPipelineCache= */ true, context);
+
+    // Assert
+    ASSERT_EQ(FileEventMonitor::AwaitResult::Success,
+              monitorCreateResult.monitor->awaitWriteOrTimeout());
+    auto result =
+            PersistentGraphicsCacheTestUtils::newCache(file->path()).load(*getPipelineCacheKey());
+    ASSERT_NE(nullptr, result);
+    ASSERT_NE(nullptr, result->data());
+    ASSERT_GT(result->size(), 0);
+}
+
+RENDERTHREAD_TEST(PersistentGraphicsCacheTest,
+                  hasPipelineCreationCacheControl_oldCache_isNotStored) {
+    // Arrange
+    ENSURE_FLAG_ENABLED();
+    VK_ONLY();
+
+    auto context = renderThread.getGrContext();
+
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+
+    auto& cache = PersistentGraphicsCacheTestUtils::newCache(file->path());
+
+    auto monitorCreateResult = FileEventMonitor::create(file->path());
+    ASSERT_SUCCESS(monitorCreateResult);
+
+    // Act
+    PersistentGraphicsCacheTestUtils::onVkFrameFlushed(cache,
+                                                       /* canDetectNewVkPipelineCacheData= */ true,
+                                                       /* hasNewPipelineCache= */ false, context);
+
+    // Assert
+    ASSERT_EQ(FileEventMonitor::AwaitResult::TimedOut,
+              monitorCreateResult.monitor->awaitWriteOrTimeout());
+    auto result =
+            PersistentGraphicsCacheTestUtils::newCache(file->path()).load(*getPipelineCacheKey());
+    ASSERT_EQ(nullptr, result);
+}
+
+RENDERTHREAD_TEST(PersistentGraphicsCacheTest,
+                  noPipelineCreationCacheControl_newCacheBySize_isStored) {
+    // Arrange
+    ENSURE_FLAG_ENABLED();
+    VK_ONLY();
+
+    auto context = renderThread.getGrContext();
+
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+
+    auto& cache = PersistentGraphicsCacheTestUtils::newCache(file->path());
+
+    auto monitorCreateResult = FileEventMonitor::create(file->path());
+    ASSERT_SUCCESS(monitorCreateResult);
+
+    // Act
+    // Current cache size is 0, so cache is new by size
+    PersistentGraphicsCacheTestUtils::onVkFrameFlushed(cache,
+                                                       /* canDetectNewVkPipelineCacheData= */ false,
+                                                       /* hasNewPipelineCache= */ true, context);
+
+    // Assert
+    ASSERT_EQ(FileEventMonitor::AwaitResult::Success,
+              monitorCreateResult.monitor->awaitWriteOrTimeout());
+    auto result =
+            PersistentGraphicsCacheTestUtils::newCache(file->path()).load(*getPipelineCacheKey());
+    ASSERT_NE(nullptr, result);
+    ASSERT_NE(nullptr, result->data());
+    ASSERT_GT(result->size(), 0);
+}
+
+RENDERTHREAD_TEST(PersistentGraphicsCacheTest,
+                  noPipelineCreationCacheControl_oldCacheBySize_isNotStored) {
+    // Arrange
+    ENSURE_FLAG_ENABLED();
+    VK_ONLY();
+
+    auto context = renderThread.getGrContext();
+
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+
+    auto& cache = PersistentGraphicsCacheTestUtils::newCache(file->path());
+
+    auto monitorCreateResult = FileEventMonitor::create(file->path());
+    ASSERT_SUCCESS(monitorCreateResult);
+
+    // Current cache size is 0, so cache is new by size
+    PersistentGraphicsCacheTestUtils::onVkFrameFlushed(cache,
+                                                       /* canDetectNewVkPipelineCacheData= */ false,
+                                                       /* hasNewPipelineCache= */ true, context);
+    ASSERT_EQ(FileEventMonitor::AwaitResult::Success,
+              monitorCreateResult.monitor->awaitWriteOrTimeout());
+
+    // Act
+    // Cache size has not changed, so cache is old by size
+    PersistentGraphicsCacheTestUtils::onVkFrameFlushed(cache,
+                                                       /* canDetectNewVkPipelineCacheData= */ false,
+                                                       /* hasNewPipelineCache= */ true, context);
+
+    // Assert
+    ASSERT_EQ(FileEventMonitor::AwaitResult::TimedOut,
+              monitorCreateResult.monitor->awaitWriteOrTimeout());
+}
diff --git a/libs/hwui/tests/unit/PipelineCacheTests.cpp b/libs/hwui/tests/unit/PipelineCacheTests.cpp
new file mode 100644
index 0000000..d60867b
--- /dev/null
+++ b/libs/hwui/tests/unit/PipelineCacheTests.cpp
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <android-base/scopeguard.h>
+#include <gtest/gtest.h>
+
+#include <cstdint>
+#include <string_view>
+#include <vector>
+
+#include "pipeline/skia/PipelineCache.h"
+#include "tests/common/TestUtils.h"
+
+using namespace android::uirenderer;
+
+namespace {
+
+constexpr char kFilename[] = "blobcache.bin";
+
+}
+
+TEST(PipelineCacheTest, noFile_acquire_fails) {
+    // Arrange
+    auto file = TestFile::ensureDoesNotExist(kFilename);
+    ASSERT_TRUE(file.has_value());
+
+    // Act
+    Memory mem;
+    auto result = acquire(file->path(), mem);
+
+    // Assert
+    ASSERT_EQ(AcquireResult::OpenFailed, result.outcome);
+}
+
+TEST(PipelineCacheTest, existingFile_acquire_succeeds) {
+    // Arrange
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+    file->write("data");
+
+    // Act
+    Memory mem;
+    auto result = acquire(file->path(), mem);
+    auto _ = android::base::make_scope_guard([=]() { release(mem); });
+
+    // Assert
+    ASSERT_EQ(AcquireResult::Success, result.outcome);
+    ASSERT_EQ(std::string_view("data"),
+              std::string_view(static_cast<const char*>(mem.data), mem.size));
+}
+
+TEST(PipelineCacheTest, existingFile_acquireAndRelease_succeeds) {
+    // Arrange
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+    file->write("data");
+
+    // Act
+    Memory mem;
+    auto acquireResult = acquire(file->path(), mem);
+    auto releaseResult = release(mem);
+
+    // Assert
+    ASSERT_EQ(AcquireResult::Success, acquireResult.outcome);
+    ASSERT_EQ(ReleaseResult::Success, releaseResult.outcome);
+}
+
+TEST(PipelineCacheTest, existingFile_backgroundWrite_overwritesData) {
+    // Arrange
+    auto file = TestFile::ensureExistsEmpty(kFilename);
+    ASSERT_TRUE(file.has_value());
+    file->write("data");
+    auto monitorCreateResult = FileEventMonitor::create(file->path());
+    ASSERT_SUCCESS(monitorCreateResult);
+    PipelineCacheStore cache(0);
+
+    // Act
+    cache.store(file->path(), std::vector<uint8_t>({'b', 'l', 'o', 'b'}));
+
+    // Assert
+    ASSERT_EQ(FileEventMonitor::AwaitResult::Success,
+              monitorCreateResult.monitor->awaitWriteOrTimeout());
+    Memory mem;
+    auto result = acquire(file->path(), mem);
+    auto _ = android::base::make_scope_guard([=]() { release(mem); });
+    ASSERT_EQ(AcquireResult::Success, result.outcome);
+    ASSERT_EQ(std::string_view("blob"),
+              std::string_view(static_cast<const char*>(mem.data), mem.size));
+}
diff --git a/libs/hwui/tests/unit/ShaderCacheTests.cpp b/libs/hwui/tests/unit/ShaderCacheTests.cpp
index b714534..fba11e6 100644
--- a/libs/hwui/tests/unit/ShaderCacheTests.cpp
+++ b/libs/hwui/tests/unit/ShaderCacheTests.cpp
@@ -371,6 +371,10 @@
 
 using namespace android::uirenderer;
 RENDERTHREAD_TEST(ShaderCacheTest, testOnVkFrameFlushed) {
+    if (hwui_flags::separate_pipeline_cache()) {
+        GTEST_SKIP() << "This test is only applicable when the separate_pipeline_cache aconfig "
+                        "flag is disabled";
+    }
     if (Properties::getRenderPipelineType() != RenderPipelineType::SkiaVulkan) {
         // RENDERTHREAD_TEST declares both SkiaVK and SkiaGL variants.
         GTEST_SKIP() << "This test is only applicable to RenderPipelineType::SkiaVulkan";