[squeak-dev] Improving WeakKeyDictionary comment
Levente Uzonyi
leves at caesar.elte.hu
Mon Jun 6 09:00:44 UTC 2022
Hi Chris,
On Mon, 6 Jun 2022, Chris Muller wrote:
> Hi Levente,
>
>>> > - Must one ensure that #finalizeValues is sent to one's WeakKeyDictionary instance regularly?
>>>
>>> Yes. Either that, or you need to otherwise somehow remove the garbage collected entries from its internal array.
>>>
>>> Otherwise, the WeakKeyDictionary will only ever grow in size internally, with a bunch of nil keys. Here's a short script showing the mechanics and assumptions of this:
>>
>> No, that is not the case. Associations with nil keys get removed
>> automatically, which is why you cannot have nil keys in
>> WeakKeyDictionaries (and because there was no demand to implement
>> something like what has been done for Sets).
>>
>> What you mean is that associations do not get removed immediately.
>> Here's is a snippet demonstrating the situation by only sending #at:put:
>> to the dictionary and always with a new key:
>>
>> | w |
>> w := WeakKeyDictionary new.
>> 1000 timesRepeat: [ w at: Object new put: 0 ].
>> Transcript open; showln: { w size. w slowSize. w capacity }.
>> w capacity - w size timesRepeat: [ w at: Object new put: 0 ].
>> Smalltalk garbageCollectMost.
>> w at: Object new put: 0.
>> Smalltalk garbageCollectMost.
>> Transcript showln: { w size. w slowSize. w capacity }.
>>
>>
>> The following lines should appear on the transcript:
>> #(1000 0 1817)
>> #(1 0 2)
>
> Okay, that must be new. In 5.3, I still get:
>
> #(1000 1000 2423)
> #(2424 0 5119)
>
> but, in trunk, yes! That's great, thanks for letting me know, but I'm
> having trouble identifying _why_ it works like that in trunk -- what
> change actually enabled that? It's something Igor and I worked on
Well, it works in 5.3 as well, but the snippet I sent assumes that
#capacity returns the capacity - the number of elements the collection can
hold without growing.
For 5.3 and before the corresponding snippet is:
w := WeakKeyDictionary new.
1000 timesRepeat: [ w at: Object new put: 0 ].
Transcript open; showln: { w size. w slowSize. w capacity }.
w capacity * 3 // 4 - w size timesRepeat: [ w at: Object new put: 0 ].
Smalltalk garbageCollectMost.
w at: Object new put: 0.
Smalltalk garbageCollectMost.
Transcript showln: { w size. w slowSize. w capacity }.
> back in 2010 (I incorrectly said 2007, before), but it involved a VM
> change we couldn't get pushed through.
>
>> The dictionary will eventually shrink to the smallest possible
>> capacity, 2,
>
> Wow, even smaller than the default initial capacity, 3, now there's a
> pleasant surprise. ;) Does that mean if I do a "WeakKeyDictionary
> new" and never add any entries to it, its capacity will eventually
> shrink to 2?
No, it won't. You need to trigger the automatic resizing of the
dictionary, and it's pretty hard without adding new entries to it.
>
>> holding one association to be garbage collected and no
>> associations with a valid key.
>>
>> As with all weak hashed collections, #isEmpty, #size, #notEmpty are not
>> reliable because the entries may vanish any time, so there's no effort to
>> even try to make those correct.
>
> Hm, Marcel fixed #isEmpty for Weak collections in 2020. There are
> contexts where those methods are useful even with the understanding of
> the potential inaccuracy. Wouldn't you consider #at: and
Thanks, now I remember that #isEmpty and #notEmpty work as expected.
> #includesKey: to also be affected (e.g., "not reliable") by this
> logic, as well?
No, #includesKey: is exact in the sense that while the method was being
executed the result matches the status of the requested key.
Though a GC may happen right after #includesKey: returns rendering the
result incorrect.
>
>> If you need accurate information, you must check against #slowSize, which
>> means O(n) runtime. Using ephemerons may help with that.
>
> I hope to one day understand ephemerons and why they're useful,
> because I sense they provide some kind of magic that could help my
> stack. But, I never grok'd them so far.
>
>>> Igor made some alternative-implementation WeakKeyDictionary's for Magma back in 2007 which are faster than the one's built into Squeak to this day (last I checked, anyway). If interested, load "MaBase" from SqueakMap and
>>> then see MaWeakIdentityKeyDictionary.
>>
>> Is it really faster?
>> Here's a basic benchmark:
>>
>> [
>> | m |
>> Smalltalk garbageCollect.
>> m := MaDictionary new.
>> {
>> #insert -> [ 1 to: 1000000 do: [ :i | m at: i hashMultiply put: i ] ] timeToRun.
>> #access -> [ 1 to: 1000000 do: [ :i | m at: i hashMultiply ] ] timeToRun.
>> #update -> [ 1 to: 1000000 do: [ :i | m at: i hashMultiply put: i + 1 ] ] timeToRun.
>> #positiveLookup -> [ 1 to: 1000000 do: [ :i | m includesKey: i hashMultiply ] ] timeToRun.
>> #negativeLookup -> [ 1000001 to: 2000000 do: [ :i | m includesKey: i hashMultiply ] ] timeToRun.
>> #remove -> [ 1 to: 1000000 do: [ :i | m removeKey: i hashMultiply ] ] timeToRun
>> } ] value.
>>
>> "{#insert->535 . #access->1062 . #update->250 . #positiveLookup->967 . #negativeLookup->1055 . #remove->2353}"
>> The same benchmark for Dictionary yields
>> {#insert->469 . #access->945 . #update->198 . #positiveLookup->174 . #negativeLookup->301 . #remove->719} on my machine.
>> Even with MaDictionary >> #checkForUnderflow: patched to avoid the creation of Fractions.
>
> Yeah, one Fraction per resize isn't going to be noticeable, but thanks
> for pointing it out, I patched it.
I noticed it by running #timeProfile and it showed up with IIRC 20% of the
runtime. Creating a fraction is not cheap, but I think the resizing
algorithm of MaDictionary is broken.
I created MaDictionary2, which uses a different hash function, a
different table size, and a conservative resize-strategy. The performance
difference is amazing, give it a try:
https://leves.web.elte.hu/squeak/Ma-Collections-ul.169.mcz
I think the difference comes from better cache locality, but it's
just a wild guess. I have no idea how it became so fast even though it
lacks the typical micro-optimizations one would apply there.
Levente
>
> MaDictionary is actually just an abstract superclass Igor made to
> contain his work, it wasn't meant to be used. The real purpose of his
> work was the replacement of Squeak's woeful WeakKeyIdentityDictionary
> from the days when the identityHash was only 12-bits. Your bench
> didn't have any collisions, but that's what the Ma variant's optimized
> for.
>
> But Squeak has come a long way since the 12-bit identityHash and
> #hashMultiply, so the benefit is not nearly as pronounced, but I
> enhanced your basic bench to try to more-closely emulate how Magma
> would be using them for, and shows that it's still a _little_ (albeit,
> not much) better than WeakKeyIdentityDictionary with #at: and
> #at:put:...
>
> { WeakIdentityKeyDictionary. MaWeakIdentityKeyDictionary}
> collect: [ : cls |
> [
> | m r o testSize |
> testSize := 1e6.
> o := Object new.
> r := (1 to: testSize) collect: [ : e | Object new ].
> r := r shuffled. "mix up identityHash's to simulate
> real-world access, just in case consecutive creation above might
> artificially help the hash algo"
> m := cls new.
> Smalltalk garbageCollect.
> { m class -> testSize.
> #insert -> [ r do: [ :i | m at: i put: i ] ] timeToRun.
> r := r shuffled.
> #access -> [ r do: [ :i | m at: i ] ] timeToRun.
> r := r shuffled.
> #negativeaccess -> [ r do: [ :i | m at: o ifAbsent:
> [nil] ] ] timeToRun.
> r := r shuffled.
> #update -> [ r do: [ :i | m at: i put: 0 ] ] timeToRun.
> r := r shuffled.
> #positiveLookup -> [ r do: [ :i | m includesKey: i ] ]
> timeToRun.
> r := r shuffled.
> #negativeLookup -> [ 1000001 to: 2000000 do: [ :i | m
> includesKey: o ] ] timeToRun.
> } ] value select: [ : e | e isVariableBinding ] ].
>
> {{WeakIdentityKeyDictionary->1000000 . #insert->1830 . #access->1260 .
> #negativeaccess->705 . #update->532 . #positiveLookup->524 .
> #negativeLookup->42} .
> {MaWeakIdentityKeyDictionary->1000000 . #insert->1286 . #access->1170
> . #negativeaccess->739 . #update->435 . #positiveLookup->1131 .
> #negativeLookup->800}}
>
> I never noticed #includesKey: to be so slow on Ma (especially the
> negative case, wow!), and I think Magma does use that in one place,
> but most of the usage is for #at: and #at:put: which get even better
> as the sizes get beyond 1M elements.
>
> But with 6.0's new auto-cleaning, I will certainly retire these Ma dictionary's!
>
> I always learn useful stuff from you, thanks a lot for your help.
>
> - Chris
>
More information about the Squeak-dev
mailing list
|