Introduce AttributeAccessInterface lookup cache (#32414)

* Introduce AttributeAccessInterface lookup cache

- Running TC_DeviceBasicComposition test caused, for a single Wildcard read,
  44622 iterations through the AttributeAccessInterface (AAI) lookup loop. This
  is significant runtime cost on embedded devices, when a wildcard subscription
  is established or when trying to iterate over changes of attributes.
- Instrumented analysis showed that significant locality exists within
  the lookup, which could be exploited by a cache.
- This PR introduces a cache, and its use, in AAI lookups. On same workload as
  the initial investigation, the total number of iterations of the costly loop
  went from 44622 --> 4864, a factor of almost 10, for very little additional
  RAM usage (single entry in cache).

Issue #31405

Testing done:
- Added unit tests
- Integration tests still pass
- TC_BasicDeviceComposition still succeeds

* Restyled by clang-format

* Appease the Gods of Lint with a BUILD.gn entry

* Address review comments by simplifying interface

* Restyled by clang-format

* Update src/app/AttributeAccessInterfaceCache.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

---------

Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>
diff --git a/src/app/AttributeAccessInterfaceCache.h b/src/app/AttributeAccessInterfaceCache.h
new file mode 100644
index 0000000..9268a70
--- /dev/null
+++ b/src/app/AttributeAccessInterfaceCache.h
@@ -0,0 +1,145 @@
+/*
+ *
+ *    Copyright (c) 2024 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    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 <stddef.h>
+
+#include <app/AttributeAccessInterface.h>
+#include <lib/core/DataModelTypes.h>
+
+namespace chip {
+namespace app {
+
+/**
+ * @brief Cache to make look-up of AttributeAccessInterface (AAI) instances faster.
+ *
+ * This cache makes use of the fact that looking-up AttributeAccessInterface
+ * instances is usually done in loops, during read/subscription wildcard
+ * expansion, and there is a significant amount of locality.
+ *
+ * This cache records both "used" (i.e. uses AAI) and the single last
+ * "unused" (i.e. does NOT use AAI) entries. Combining positive/negative
+ * lookup led to factor of ~10 reduction of AAI lookups in total for wildcard
+ * reads on chip-all-clusters-app, with a cache size of 1. Increasing the size did not
+ * significantly improve the performance.
+ */
+class AttributeAccessInterfaceCache
+{
+public:
+    enum class CacheResult
+    {
+        kCacheMiss,
+        kDefinitelyUnused,
+        kDefinitelyUsed
+    };
+
+    AttributeAccessInterfaceCache() { Invalidate(); }
+
+    /**
+     * @brief Invalidate the whole cache. Must be called every time list of AAI registrations changes.
+     */
+    void Invalidate()
+    {
+        for (auto & entry : mCacheSlots)
+        {
+            entry.Invalidate();
+        }
+        mLastUnusedEntry.Invalidate();
+    }
+
+    /**
+     * @brief Mark that we know a given <`endpointId`, `clusterId`> uses AAI, with instance `attrInterface`
+     */
+    void MarkUsed(EndpointId endpointId, ClusterId clusterId, AttributeAccessInterface * attrInterface)
+    {
+        GetCacheSlot(endpointId, clusterId)->Set(endpointId, clusterId, attrInterface);
+    }
+
+    /**
+     * @brief Mark that we know a given <`endpointId`, `clusterId`> does NOT use AAI.
+     */
+    void MarkUnused(EndpointId endpointId, ClusterId clusterId) { mLastUnusedEntry.Set(endpointId, clusterId, nullptr); }
+
+    /**
+     * @brief Get the AttributeAccessInterface instance for a given <`endpointId`, `clusterId`>, if present in cache.
+     *
+     * @param endpointId - Endpoint ID to look-up.
+     * @param clusterId - Cluster ID to look-up.
+     * @param outAttributeAccess - If not null, and Get returns `kDefinitelyUsed`, then this is set to the instance pointer.
+     * @return a for whether the entry is actually used or not.
+     */
+    CacheResult Get(EndpointId endpointId, ClusterId clusterId, AttributeAccessInterface ** outAttributeAccess)
+    {
+        if (mLastUnusedEntry.Matches(endpointId, clusterId))
+        {
+            return CacheResult::kDefinitelyUnused;
+        }
+
+        AttributeAccessCacheEntry * cacheSlot = GetCacheSlot(endpointId, clusterId);
+        if (cacheSlot->Matches(endpointId, clusterId) && (cacheSlot->accessor != nullptr))
+        {
+            if (outAttributeAccess != nullptr)
+            {
+                *outAttributeAccess = cacheSlot->accessor;
+            }
+            return CacheResult::kDefinitelyUsed;
+        }
+
+        return CacheResult::kCacheMiss;
+    }
+
+private:
+    struct AttributeAccessCacheEntry
+    {
+        EndpointId endpointId               = kInvalidEndpointId;
+        ClusterId clusterId                 = kInvalidClusterId;
+        AttributeAccessInterface * accessor = nullptr;
+
+        void Invalidate()
+        {
+            endpointId = kInvalidEndpointId;
+            clusterId  = kInvalidClusterId;
+            accessor   = nullptr;
+        }
+
+        void Set(EndpointId theEndpointId, ClusterId theClusterId, AttributeAccessInterface * theAccessor)
+        {
+            endpointId = theEndpointId;
+            clusterId  = theClusterId;
+            accessor   = theAccessor;
+        }
+
+        bool Matches(EndpointId theEndpointId, ClusterId theClusterId) const
+        {
+            return (endpointId == theEndpointId) && (clusterId == theClusterId);
+        }
+    };
+
+    AttributeAccessCacheEntry * GetCacheSlot(EndpointId endpointId, ClusterId clusterId)
+    {
+        (void) endpointId;
+        (void) clusterId;
+        return &mCacheSlots[0];
+    }
+
+    AttributeAccessCacheEntry mCacheSlots[1];
+    AttributeAccessCacheEntry mLastUnusedEntry;
+};
+
+} // namespace app
+} // namespace chip
diff --git a/src/app/BUILD.gn b/src/app/BUILD.gn
index 618e51d..1d76b5b 100644
--- a/src/app/BUILD.gn
+++ b/src/app/BUILD.gn
@@ -250,6 +250,7 @@
 
   sources = [
     "AttributeAccessInterface.cpp",
+    "AttributeAccessInterfaceCache.h",
     "AttributePathExpandIterator.cpp",
     "AttributePathExpandIterator.h",
     "AttributePersistenceProvider.h",
diff --git a/src/app/tests/BUILD.gn b/src/app/tests/BUILD.gn
index e4ab603..78b9c8f 100644
--- a/src/app/tests/BUILD.gn
+++ b/src/app/tests/BUILD.gn
@@ -125,6 +125,7 @@
 
   test_sources = [
     "TestAclEvent.cpp",
+    "TestAttributeAccessInterfaceCache.cpp",
     "TestAttributePathExpandIterator.cpp",
     "TestAttributePersistenceProvider.cpp",
     "TestAttributeValueDecoder.cpp",
diff --git a/src/app/tests/TestAttributeAccessInterfaceCache.cpp b/src/app/tests/TestAttributeAccessInterfaceCache.cpp
new file mode 100644
index 0000000..47b8e6d
--- /dev/null
+++ b/src/app/tests/TestAttributeAccessInterfaceCache.cpp
@@ -0,0 +1,129 @@
+/*
+ *
+ *    Copyright (c) 2024 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    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 <app/AttributeAccessInterface.h>
+#include <app/AttributeAccessInterfaceCache.h>
+#include <lib/support/UnitTestRegistration.h>
+#include <nlunit-test.h>
+
+using namespace chip;
+using namespace chip::app;
+
+namespace {
+
+void TestBasicLifecycle(nlTestSuite * inSuite, void * inContext)
+{
+    using CacheResult = AttributeAccessInterfaceCache::CacheResult;
+
+    int data1 = 1;
+    int data2 = 2;
+
+    // We alias the pointers to given locations to avoid needing to implement anything
+    // since the AttributeAccessInterfaceCache only deals in pointers, and never calls
+    // the API itself.
+    AttributeAccessInterface * accessor1 = reinterpret_cast<AttributeAccessInterface *>(&data1);
+    AttributeAccessInterface * accessor2 = reinterpret_cast<AttributeAccessInterface *>(&data2);
+
+    AttributeAccessInterfaceCache cache;
+
+    // Cache can keep track of at least 1 entry,
+    AttributeAccessInterface * entry = nullptr;
+
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 1, &entry) == CacheResult::kCacheMiss);
+    NL_TEST_ASSERT(inSuite, entry == nullptr);
+    cache.MarkUsed(1, 1, accessor1);
+
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 1, &entry) == CacheResult::kDefinitelyUsed);
+    NL_TEST_ASSERT(inSuite, entry == accessor1);
+
+    entry = nullptr;
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 2, &entry) == CacheResult::kCacheMiss);
+    NL_TEST_ASSERT(inSuite, entry == nullptr);
+    NL_TEST_ASSERT(inSuite, cache.Get(2, 1, &entry) == CacheResult::kCacheMiss);
+    NL_TEST_ASSERT(inSuite, entry == nullptr);
+
+    cache.MarkUsed(1, 2, accessor1);
+
+    entry = nullptr;
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 2, &entry) == CacheResult::kDefinitelyUsed);
+    NL_TEST_ASSERT(inSuite, entry == accessor1);
+    NL_TEST_ASSERT(inSuite, cache.Get(2, 1, &entry) == CacheResult::kCacheMiss);
+
+    cache.MarkUsed(1, 2, accessor2);
+
+    entry = nullptr;
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 2, &entry) == CacheResult::kDefinitelyUsed);
+    NL_TEST_ASSERT(inSuite, entry == accessor2);
+    // The following should not crash (e.g. output not used if nullptr).
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 2, nullptr) == CacheResult::kDefinitelyUsed);
+
+    // Setting used to nullptr == does not mark used.
+    cache.MarkUsed(1, 2, nullptr);
+    entry = nullptr;
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 2, &entry) == CacheResult::kCacheMiss);
+    NL_TEST_ASSERT(inSuite, entry == nullptr);
+
+    cache.Invalidate();
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 1, &entry) == CacheResult::kCacheMiss);
+    NL_TEST_ASSERT(inSuite, entry == nullptr);
+    NL_TEST_ASSERT(inSuite, cache.Get(1, 2, &entry) == CacheResult::kCacheMiss);
+    NL_TEST_ASSERT(inSuite, cache.Get(2, 1, &entry) == CacheResult::kCacheMiss);
+
+    // Marking unused works, keeps single entry, and is invalidated when invalidated fully.
+    NL_TEST_ASSERT(inSuite, cache.Get(2, 2, nullptr) != CacheResult::kDefinitelyUnused);
+    NL_TEST_ASSERT(inSuite, cache.Get(3, 3, nullptr) != CacheResult::kDefinitelyUnused);
+    cache.MarkUnused(2, 2);
+    NL_TEST_ASSERT(inSuite, cache.Get(2, 2, nullptr) == CacheResult::kDefinitelyUnused);
+    NL_TEST_ASSERT(inSuite, cache.Get(3, 3, nullptr) != CacheResult::kDefinitelyUnused);
+
+    cache.MarkUnused(3, 3);
+    NL_TEST_ASSERT(inSuite, cache.Get(2, 2, nullptr) != CacheResult::kDefinitelyUnused);
+    NL_TEST_ASSERT(inSuite, cache.Get(3, 3, nullptr) == CacheResult::kDefinitelyUnused);
+
+    cache.Invalidate();
+    NL_TEST_ASSERT(inSuite, cache.Get(3, 3, nullptr) != CacheResult::kDefinitelyUnused);
+}
+
+// clang-format off
+const nlTest sTests[] =
+{
+    NL_TEST_DEF("Basic AttributeAccessInterfaceCache lifecycle works", TestBasicLifecycle),
+    NL_TEST_SENTINEL()
+};
+// clang-format on
+
+} // namespace
+
+int TestAttributeAccessInterfaceCache()
+{
+    // clang-format off
+    nlTestSuite theSuite =
+    {
+        "Test for AttributeAccessInterface cache utility",
+        &sTests[0],
+        nullptr,
+        nullptr
+    };
+    // clang-format on
+
+    nlTestRunner(&theSuite, nullptr);
+
+    return (nlTestRunnerStats(&theSuite));
+}
+
+CHIP_REGISTER_TEST_SUITE(TestAttributeAccessInterfaceCache)
diff --git a/src/app/util/attribute-storage.cpp b/src/app/util/attribute-storage.cpp
index 35fd9ce..431f309 100644
--- a/src/app/util/attribute-storage.cpp
+++ b/src/app/util/attribute-storage.cpp
@@ -17,6 +17,7 @@
 
 #include <app/util/attribute-storage.h>
 
+#include <app/AttributeAccessInterfaceCache.h>
 #include <app/AttributePersistenceProvider.h>
 #include <app/InteractionModelEngine.h>
 #include <app/reporting/reporting.h>
@@ -136,6 +137,7 @@
 DataVersion fixedEndpointDataVersions[ZAP_FIXED_ENDPOINT_DATA_VERSION_COUNT];
 
 AttributeAccessInterface * gAttributeAccessOverrides = nullptr;
+AttributeAccessInterfaceCache gAttributeAccessInterfaceCache;
 
 // shouldUnregister returns true if the given AttributeAccessInterface should be
 // unregistered.
@@ -1364,6 +1366,7 @@
 
 bool registerAttributeAccessOverride(AttributeAccessInterface * attrOverride)
 {
+    gAttributeAccessInterfaceCache.Invalidate();
     for (auto * cur = gAttributeAccessOverrides; cur; cur = cur->GetNext())
     {
         if (cur->Matches(*attrOverride))
@@ -1379,19 +1382,39 @@
 
 void unregisterAttributeAccessOverride(AttributeAccessInterface * attrOverride)
 {
+    gAttributeAccessInterfaceCache.Invalidate();
     UnregisterMatchingAttributeAccessInterfaces([attrOverride](AttributeAccessInterface * entry) { return entry == attrOverride; });
 }
 
 namespace chip {
 namespace app {
+
 app::AttributeAccessInterface * GetAttributeAccessOverride(EndpointId endpointId, ClusterId clusterId)
 {
-    for (app::AttributeAccessInterface * cur = gAttributeAccessOverrides; cur; cur = cur->GetNext())
+    using CacheResult = AttributeAccessInterfaceCache::CacheResult;
+
+    AttributeAccessInterface * cached = nullptr;
+    CacheResult result                = gAttributeAccessInterfaceCache.Get(endpointId, clusterId, &cached);
+    switch (result)
     {
-        if (cur->Matches(endpointId, clusterId))
+    case CacheResult::kDefinitelyUnused:
+        return nullptr;
+    case CacheResult::kDefinitelyUsed:
+        return cached;
+    case CacheResult::kCacheMiss:
+    default:
+        // Did not cache yet, search set of AAI registered, and cache if found.
+        for (app::AttributeAccessInterface * cur = gAttributeAccessOverrides; cur; cur = cur->GetNext())
         {
-            return cur;
+            if (cur->Matches(endpointId, clusterId))
+            {
+                gAttributeAccessInterfaceCache.MarkUsed(endpointId, clusterId, cur);
+                return cur;
+            }
         }
+
+        // Did not find AAI registered: mark as definitely not using.
+        gAttributeAccessInterfaceCache.MarkUnused(endpointId, clusterId);
     }
 
     return nullptr;