-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Optimize both allocated and retained memory usage #17
Conversation
That's cool.
Which is is for sure much better than from the original from master (baseline):
But I took your findings about producing arrays from the hash keys one step further and I got this without the need for the new class
Thank you so much for this contribution. I will be merging it soon. |
Ah, you're right, I missed that the memory got reattributed to Set. Bummer. Was really hoping to get the retained memory further down, although reducing initial allocations is still a solid win. Thanks for double-checking my work and for your ongoing work on this gem! 🥇 |
What's caught my attention is that the raw data is only 788KB on disk. So, I've experimented with this a bit more. There is additional memory savings available, but only with a notable performance hit. In brief, this would look like storing the internal data in large, delimited strings to avoid the overhead of so many arrays or hashes, strings, and integers. For example, such a string might look like Holding each dictionary in one giant string (ie: 6 strings total) drops the retained memory down to 1.4MB (compared to 7.2MB in the above comment). While this is almost double the raw data, some of that is from the extra However, despite the memory savings, the test suite is about 180x slower! 🐢 Grouping each dictionary by first character ( Lastly, grouping each by the first 2 characters results in the test suite being only 5x slower. Near as I can tell, the test suite performs approximately 315000 word lookups. On my system, the last commit above runs in 0.25 sec. 5x slower is 1.25sec, or a loss of 1sec across 315k lookups. 10x slower is 2.5sec, or a loss of 2.25sec across 315k (around .007ms per lookup). Assuming I've measured things properly this time, this is at least an interesting result. I'm not sure how anyone feels about a 5-10x decrease in performance in exchange for reducing memory by ~5.5MB (per process, of course). Anyway, mostly thought I'd share my results in case anyone else feels compelled to think about this more. It's a curious study into Ruby's memory management in light of working with a dictionary of 100k words. If it's somehow super interesting and potentially worth merging, feel free to chase it or I'd also be happy to work up a follow-on PR to the present one (or append it here, if preferred). |
I think with this PR you have found the most annoying part of the problem that was causing the double of the strings to be loaded. This was huge. |
Hey @zarqman, have a look at PR #18. It allow you to not retain any of the dictionaries. I also checked the number of objects loaded, there are around of 94k entries in the dictionaries which accounts for most of the gem allocations. |
I've left most of my comments on #18.
Agreed, to reduce the memory used will require a significant change in storage and search. That's the experiment I was discussing in my last comment above. It does work, but it's a bit messy. Performance hit on my laptop was only a fraction of 1ms per test iteration. That's likely much less than the hit of loading/unloading/garbage-collecting the entire dictionary. However, even that experiment still takes 1.4MB of memory perpetually. That's a fair bit more than the 0 MB used after unloading the dictionaries. 😄 |
Inspired by #12, #13, and #15, I decided to play with this a bit and see if memory optimization could be taken even farther. Turns out, it seems so.
Experiment 1
Freeze the strings read from
frequency_lists/*.txt
(great idea to make this external, BTW). Seefrequency_lists.rb:16
.This reduced initial memory allocations a good bit.
Experiment 2
Avoid allocating an Array for
keys
when computingRANKED_DICTIONARIES_MAX_WORD_SIZE
.In
matching.rb:34
, this change would have been to replacewith
This reduced initial allocations a little bit more. However, in combo with experiment 3, it doesn't seem necessary.
Experiment 3
Change storage of ranked dictionaries from a Hash to a dual Array + Set, using a new RankedDict class as a shim of sorts. The Array is used as both the original list of keys and to determine the rank aka index. The Set is used for fast lookups.
Using Array alone takes a tiny bit less memory, but is 10x slower for lookups.
By adding a new
RankedDict
class, almost nothing else had to change. It defines just enough methods to provide compatibility with the prior existing usage of Hash. I know one goal of the project is to minimize changes with the JS implementation and this seemed to be the least invasive way to swap out the data structures.This reduces initial allocations even further over Experiment 1 alone and also reduces retained memory a fair bit.
Results
This PR includes experiments 1 and 3. With experiment 3, experiment 2 didn't seem to be needed since it's now operating on the array from RankedDict anyway.
Overall performance seems to be about the same.
The memory savings are notable, at least on MRI Ruby 3.3. Not sure how it might compare to other Rubies.
Initial allocations: reduced by 57%
Retained memory: reduced by 33%
Memory measurements generated with
derailed bundle:objects
on a local project that useszxcvbn-rb
.Before
After