Skip to content

Commit 182cebe

Browse files
pookjwKyle-Ye
andauthored
Fix ObjectCache LRU implementation (#631)
Co-authored-by: Kyle <[email protected]>
1 parent 2f62fe3 commit 182cebe

File tree

2 files changed

+226
-17
lines changed

2 files changed

+226
-17
lines changed

Sources/OpenSwiftUICore/Data/Util/ObjectCache.swift

Lines changed: 133 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,70 @@
22
// ObjectCache.swift
33
// OpenSwiftUICore
44
//
5-
// Audited for 6.0.87
5+
// Audited for 6.5.4
66
// Status: Complete
7-
// ID: FCB2944DC319042A861E82C8B244E212
7+
// ID: FCB2944DC319042A861E82C8B244E212 (SwiftUICore)
88

9+
/// A thread-safe cache that stores key-value pairs with automatic eviction.
10+
///
11+
/// `ObjectCache` implements a set-associative cache with LRU (Least Recently Used)
12+
/// eviction policy. When a bucket is full and a new item needs to be inserted, the least
13+
/// recently used item in that bucket is evicted.
14+
///
15+
/// For example:
16+
///
17+
/// let cache = ObjectCache<String, ExpensiveObject> { key in
18+
/// ExpensiveObject(key: key)
19+
/// }
20+
///
21+
/// let value = cache["myKey"]
22+
///
923
final package class ObjectCache<Key, Value> where Key: Hashable {
24+
25+
/// The constructor function used to create new values for cache misses.
1026
let constructor: (Key) -> Value
11-
27+
28+
/// The internal cache data structure, protected by atomic access.
1229
@AtomicBox
1330
private var data: Data
14-
31+
32+
/// Creates a new cache with the specified constructor function.
33+
///
34+
/// - Parameter constructor: A closure that creates a value for a given key.
35+
/// This closure is called when a key is accessed but not found in the cache.
1536
@inlinable
1637
package init(constructor: @escaping (Key) -> Value) {
1738
self.constructor = constructor
1839
self.data = Data()
1940
}
20-
41+
42+
/// Accesses the value associated with the given key.
43+
///
44+
/// If the key exists in the cache, returns the cached value and updates its
45+
/// access time. If the key doesn't exist, calls the constructor to create a
46+
/// new value, stores it in the cache (potentially evicting the least recently
47+
/// used item in the same bucket), and returns the new value.
48+
///
49+
/// - Parameter key: The key to look up.
50+
/// - Returns: The value associated with the key, either from cache or newly constructed.
2151
final package subscript(key: Key) -> Value {
2252
let hash = key.hashValue
23-
let bucket = (hash & ((1 << 3) - 1)) << 2
53+
let bucket = (hash & (Data.bucketCount - 1)) * Data.waysPerBucket
2454
var targetOffset: Int = 0
2555
var diff: Int32 = Int32.min
2656
let value = $data.access { data -> Value? in
27-
for offset in 0 ..< 3 {
57+
for offset in 0 ..< Data.waysPerBucket {
2858
let index = bucket + offset
2959
if let itemData = data.table[index].data {
3060
if itemData.hash == hash, itemData.key == key {
3161
data.clock &+= 1
3262
data.table[index].used = data.clock
3363
return itemData.value
3464
} else {
35-
if diff < Int32(bitPattern: data.clock &- data.table[index].used) {
65+
let dist = Int32(bitPattern: data.clock &- data.table[index].used)
66+
if diff < dist {
3667
targetOffset = offset
37-
diff = Int32.max
68+
diff = dist
3869
}
3970
}
4071
} else {
@@ -57,24 +88,112 @@ final package class ObjectCache<Key, Value> where Key: Hashable {
5788
return value
5889
}
5990
}
60-
91+
92+
/// A cache slot that can hold an item or be empty.
93+
///
94+
/// Each slot tracks when it was last used via the `used` timestamp, which is
95+
/// compared against the global `clock` to determine the least recently used item.
6196
private struct Item {
97+
98+
/// The cached data tuple containing the key, hash, and value, or nil if empty.
6299
var data: (key: Key, hash: Int, value: Value)?
100+
101+
/// The clock value when this item was last accessed or inserted.
102+
///
103+
/// This timestamp is used for LRU eviction. When a bucket is full, the item
104+
/// with the smallest `used` value (i.e., the one with the largest time distance
105+
/// from the current clock) is evicted.
63106
var used: UInt32
64-
107+
65108
init(data: (key: Key, hash: Int, value: Value)?, used: UInt32) {
66109
self.data = data
67110
self.used = used
68111
}
69112
}
70-
113+
114+
/// The internal data structure holding the cache table and global clock.
71115
private struct Data {
116+
117+
/// The number of buckets in the cache.
118+
///
119+
/// The cache uses 8 buckets to distribute keys based on their hash values.
120+
/// Each bucket can hold multiple items (ways) for collision resolution.
121+
static var bucketCount: Int { 8 }
122+
123+
/// The number of ways (slots) per bucket.
124+
///
125+
/// Each bucket contains 4 ways, implementing a 4-way set-associative cache.
126+
/// When all ways in a bucket are full, the least recently used item is evicted.
127+
static var waysPerBucket: Int { 4 }
128+
129+
/// The total number of slots in the cache table.
130+
///
131+
/// Computed as `bucketCount × waysPerBucket`, resulting in 32 total cache slots.
132+
static var tableSize: Int { bucketCount * waysPerBucket }
133+
134+
/// The hash table with 32 slots (8 buckets × 4 ways per bucket).
72135
var table: [Item]
136+
137+
/// A monotonically increasing counter used for LRU tracking.
138+
///
139+
/// The `clock` is incremented on every cache access (hit or miss). Each item's
140+
/// `used` field stores the clock value at its last access. When eviction is needed,
141+
/// the item with the oldest `used` value (largest difference from current clock)
142+
/// is selected for replacement.
143+
///
144+
/// This implements a pseudo-LRU policy that efficiently approximates true LRU
145+
/// without maintaining a global ordering of all items.
73146
var clock: UInt32
74-
147+
75148
init() {
76-
self.table = Array(repeating: Item(data: nil, used: 0), count: 32)
149+
self.table = Array(repeating: Item(data: nil, used: 0), count: Self.tableSize)
77150
self.clock = 0
78151
}
79152
}
80153
}
154+
155+
#if DEBUG
156+
extension ObjectCache: CustomDebugStringConvertible {
157+
package var debugDescription: String {
158+
$data.access { data in
159+
var description = "ObjectCache(clock: \(data.clock), items: \(data.table.filter { $0.data != nil }.count)/\(Data.tableSize))\n"
160+
for (index, item) in data.table.enumerated() {
161+
if let itemData = item.data {
162+
let bucket = index / Data.waysPerBucket
163+
let offset = index % Data.waysPerBucket
164+
let age = data.clock &- item.used
165+
description += " [\(bucket):\(offset)] hash=\(itemData.hash), used=\(item.used), age=\(age)\n"
166+
}
167+
}
168+
return description
169+
}
170+
}
171+
}
172+
173+
extension ObjectCache {
174+
package var count: Int {
175+
$data.access { data in
176+
data.table.filter { $0.data != nil }.count
177+
}
178+
}
179+
180+
package var currentClock: UInt32 {
181+
$data.access { data in
182+
data.clock
183+
}
184+
}
185+
186+
package var keys: [Key] {
187+
$data.access { data in
188+
data.table.compactMap { $0.data?.key }
189+
}
190+
}
191+
192+
package func reset() {
193+
$data.access { data in
194+
data.table = Array(repeating: Item(data: nil, used: 0), count: Data.tableSize)
195+
data.clock = 0
196+
}
197+
}
198+
}
199+
#endif

Tests/OpenSwiftUICoreTests/Data/Util/ObjectCacheTests.swift

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,100 @@ import Testing
77

88
struct ObjectCacheTests {
99
@Test
10-
func example() {
11-
let cache: ObjectCache<Int, String> = ObjectCache { key in "\(key)" }
10+
func accessCount() {
11+
var accessCounts: [Int: Int] = [:]
12+
let cache: ObjectCache<Int, String> = ObjectCache { key in
13+
accessCounts[key, default: 0] += 1
14+
return "\(key)"
15+
}
16+
17+
#expect(accessCounts[0] == nil)
18+
1219
#expect(cache[0] == "0")
13-
#expect(cache[1] == "1")
20+
#expect(accessCounts[0] == 1)
21+
1422
#expect(cache[0] == "0")
23+
#expect(accessCounts[0] == 1)
24+
}
25+
26+
private struct Key: Hashable {
27+
var value: Int
28+
29+
// Intended behavior for the test case
30+
var hashValue: Int { value }
31+
32+
func hash(into hasher: inout Hasher) {
33+
// suppress warning
34+
}
35+
}
36+
37+
@Test
38+
func bucketFullEviction() {
39+
enum Count {
40+
static var deinitValue: Int?
41+
}
42+
43+
class Object {
44+
var value: Int
45+
46+
init(value: Int) {
47+
self.value = value
48+
}
49+
50+
deinit { Count.deinitValue = value }
51+
}
52+
53+
var accessCounts: [Int: Int] = [:]
54+
let cache: ObjectCache<Key, Object> = ObjectCache { key in
55+
accessCounts[key.value, default: 0] += 1
56+
return Object(value: key.value)
57+
}
58+
for key in (0 ..< 32).map(Key.init(value:)) {
59+
#expect(accessCounts[key.value] == nil)
60+
#expect(cache[key].value == key.value)
61+
#expect(accessCounts[key.value] == 1)
62+
}
63+
#expect(Count.deinitValue == nil)
64+
#if DEBUG
65+
#expect(cache.count == 32)
66+
#endif
67+
_ = cache[Key(value: 32)] // This will evict one value since the bucket is full
68+
#expect(Count.deinitValue != nil)
69+
}
70+
71+
@Test
72+
func bucketCollisionEviction() {
73+
enum Count {
74+
static var deinitOrder: [Int] = []
75+
}
76+
77+
class Object {
78+
var value: Int
79+
80+
init(value: Int) {
81+
self.value = value
82+
}
83+
84+
deinit {
85+
Count.deinitOrder.append(value)
86+
}
87+
}
88+
89+
var accessCounts: [Int: Int] = [:]
90+
let cache: ObjectCache<Key, Object> = ObjectCache { key in
91+
accessCounts[key.value, default: 0] += 1
92+
return Object(value: key.value)
93+
}
94+
for key in [0, 8, 16, 24].map(Key.init(value:)) {
95+
#expect(accessCounts[key.value] == nil)
96+
#expect(cache[key].value == key.value)
97+
#expect(accessCounts[key.value] == 1)
98+
}
99+
_ = cache[Key(value: 32)] // This will evict object for Key(value: 0)
100+
#expect(Count.deinitOrder == [0])
101+
102+
_ = cache[Key(value: 8)]
103+
_ = cache[Key(value: 40)] // This will evict object for Key(value: 16) since we have visited Key(value: 8) recently
104+
#expect(Count.deinitOrder == [0, 16])
15105
}
16106
}

0 commit comments

Comments
 (0)