You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix: build the handles for update() outside the cache lock, in chunks - #123
The update() part of #118 (see the comment there). Wrapped::extend held the cache lock while it iterated its argument and built the handles, so Python code ran under the lock: a.update(b) and b.update(a) on two threads deadlocked, and so did an iterable that reads the same cache, on a single thread:
extend no longer iterates under the lock unless the input is an exact dict, list, tuple, set or frozenset (iterating those runs no Python code, so they keep the old single pass). Anything else (a generator, another cache, a subclass) is iterated and its handles built (__hash__, getsizeof) without the lock, the way insert already does for one entry, in chunks of 1024; each chunk is inserted under one lock. The chunks keep memory bounded by the cache, not by the input: LRUCache(10).update() from a generator of 100k 1 KB values peaks at 1.1 MB. Entries read before an error in the iterable are still inserted, as before and as dict.update does. Two differences on the chunked path: a large update() is applied chunk by chunk, so another thread can see it half done; and when an entry is too large for the cache, the iterable is read to the end of its chunk (up to 1023 more items) before the OverflowError is raised.
Cost, LRUCache(0).update(...), release builds, min of 5 interleaved runs, ns per entry:
input
main
this PR
dict, 100
30.6
30.6
list of tuples, 100
23.8
23.3
another cache, 100
33.5
35.4
dict, 10 000
38.6
38.0
list of tuples, 10 000
26.0
26.3
another cache, 10 000
35.7
38.2
Tests
test_update_running_python_code (child process): the single-thread case above, then a.update(b) / b.update(a) on two threads.
Fork CI with the first test commit alone on main (run): test_update_running_python_code hangs for all 7 classes on every job (3.10–3.15, 3.14t, 3.15t, pypy3.11, ubuntu, macos, windows). With the fix (run): all jobs green.
Great ❤️ As I mentioned in issue #118 (though I replied a bit late), for types like dict, set, and tuple where we're sure they don't have this problem, we can use the old approach. But for the other types, we can apply the same fix you did.
Can you also handle this? If not, I'll fix it myself.
Done in 7fdbf4c and e08bbc3: an exact dict, list, tuple, set or frozenset goes through the old single pass under one lock, with no buffer; anything else (generators, other caches, subclasses, since a list subclass can override __iter__) uses the chunked path. On the fast path the keys' __hash__ and getsizeof still run under the lock, as before.
update() from a dict or a list is back to main's speed (within ±2%); from another cache it is about 6–7% slower.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The
update()part of #118 (see the comment there).Wrapped::extendheld the cache lock while it iterated its argument and built the handles, so Python code ran under the lock:a.update(b)andb.update(a)on two threads deadlocked, and so did an iterable that reads the same cache, on a single thread:extendno longer iterates under the lock unless the input is an exactdict,list,tuple,setorfrozenset(iterating those runs no Python code, so they keep the old single pass). Anything else (a generator, another cache, a subclass) is iterated and its handles built (__hash__,getsizeof) without the lock, the wayinsertalready does for one entry, in chunks of 1024; each chunk is inserted under one lock. The chunks keep memory bounded by the cache, not by the input:LRUCache(10).update()from a generator of 100k 1 KB values peaks at 1.1 MB. Entries read before an error in the iterable are still inserted, as before and asdict.updatedoes. Two differences on the chunked path: a largeupdate()is applied chunk by chunk, so another thread can see it half done; and when an entry is too large for the cache, the iterable is read to the end of its chunk (up to 1023 more items) before theOverflowErroris raised.Cost,
LRUCache(0).update(...), release builds, min of 5 interleaved runs, ns per entry:mainTests
test_update_running_python_code(child process): the single-thread case above, thena.update(b)/b.update(a)on two threads.test_update_from_tuple_set_and_list_subclass,test_update_keeps_entries_read_before_an_error,test_update_memory_follows_the_cache_not_the_input,test_update_ignores_a_wrong_len.Fork CI with the first test commit alone on
main(run):test_update_running_python_codehangs for all 7 classes on every job (3.10–3.15, 3.14t, 3.15t, pypy3.11, ubuntu, macos, windows). With the fix (run): all jobs green.