Git Product home page Git Product logo

memo_wise's Introduction

MemoWise

Tests Code Coverage Yard Docs Gem Version Gem Downloads

Why MemoWise?

MemoWise is the wise choice for Ruby memoization, featuring:

Installation

Add this line to your application's Gemfile:

gem 'memo_wise'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install memo_wise

Usage

When you prepend MemoWise within a class or module, MemoWise exposes three methods:

class Example
  prepend MemoWise

  def slow_value(x)
    sleep x
    x
  end
  memo_wise :slow_value

  private

  # maintains privacy of the memoized method
  def private_slow_method(x)
    sleep x
    x
  end
  memo_wise :private_slow_method
end

ex = Example.new
ex.slow_value(2) # => 2 # Sleeps for 2 seconds before returning
ex.slow_value(2) # => 2 # Returns immediately because the result is memoized

ex.reset_memo_wise(:slow_value) # Resets all memoized results for slow_value
ex.slow_value(2) # => 2 # Sleeps for 2 seconds before returning
ex.slow_value(2) # => 2 # Returns immediately because the result is memoized
# NOTE: Memoization can also be reset for all methods, or for just one argument.

ex.preset_memo_wise(:slow_value, 3) { 4 } # Store 4 as the result for slow_value(3)
ex.slow_value(3) # => 4 # Returns immediately because the result is memoized
ex.reset_memo_wise # Resets all memoized results for all methods on ex

The same three methods are exposed for class methods as well:

class Example
  prepend MemoWise

  def self.class_slow_value(x)
    sleep x
    x
  end
  memo_wise self: :class_slow_value
end

Example.class_slow_value(2) # => 2 # Sleeps for 2 seconds before returning
Example.class_slow_value(2) # => 2 # Returns immediately because the result is memoized

Example.reset_memo_wise(:class_slow_value) # Resets all memoized results for class_slow_value

Example.preset_memo_wise(:class_slow_value, 3) { 4 } # Store 4 as the result for slow_value(3)
Example.class_slow_value(3) # => 4 # Returns immediately because the result is memoized
Example.reset_memo_wise # Resets all memoized results for all methods on class

NOTE: Methods which take implicit or explicit block arguments cannot be memoized.

For more usage details, see our detailed documentation.

Benchmarks

Benchmarks are run in GitHub Actions, and the tables below are updated with every code change. Values >1.00x represent how much slower each gem’s memoized value retrieval is than the latest commit of MemoWise, according to benchmark-ips (2.11.0).

Results using Ruby 3.2.2:

Method arguments Dry::Core* (1.0.1) Memery (1.5.0)
() (none) 0.60x 3.58x
(a) 1.37x 7.41x
(a, b) 1.20x 6.43x
(a:) 1.47x 13.60x
(a:, b:) 1.20x 10.55x
(a, b:) 1.21x 10.36x
(a, *args) 0.79x 1.52x
(a:, **kwargs) 0.77x 2.02x
(a, *args, b:, **kwargs) 0.69x 1.38x

* Dry::Core may cause incorrect behavior caused by hash collisions.

Results using Ruby 2.7.8 (because these gems raise errors in Ruby 3.x):

Method arguments DDMemoize (1.0.0) Memoist (0.16.2) Memoized (1.1.1) Memoizer (1.0.3)
() (none) 22.09x 2.35x 23.72x 2.60x
(a) 20.98x 14.43x 21.20x 12.20x
(a, b) 17.45x 12.94x 17.69x 11.13x
(a:) 29.80x 23.38x 25.17x 21.57x
(a:, b:) 27.00x 22.26x 23.30x 20.91x
(a, b:) 25.91x 21.20x 21.88x 19.51x
(a, *args) 3.07x 2.27x 3.17x 1.95x
(a:, **kwargs) 2.74x 2.28x 2.51x 2.10x
(a, *args, b:, **kwargs) 2.14x 1.84x 1.95x 1.72x

You can run benchmarks yourself with:

$ cd benchmarks
$ bundle install
$ bundle exec ruby benchmarks.rb

If your results differ from what's posted here, let us know!

Thread Safety

MemoWise makes the following thread safety guarantees on all supported Ruby versions:

  1. Before a value has been memoized

    • Contended calls from multiple threads...
      • May each call the original method
      • May return different valid results (when the method is nondeterministic, like rand)
      • Will memoize exactly one valid return value
  2. After a value has been memoized

    • Contended calls from multiple threads...
      • Always return the same memoized value

Documentation

Automatically Generated Docs

We maintain API documentation using YARD, which is published automatically at RubyDoc.info.

To generate documentation locally or run documentation tests, first install the docs dependencies (e.g. yard) as follows:

BUNDLE_WITH=docs bundle install

Hot Reloading Docs Locally

To edit documentation locally and see it rendered in your browser using hot reloading, run:

bundle exec yard server --reload

You can then open your web browser to http://127.0.0.1:8808/. As you edit documentation locally, reload your browser to see it generated.

Static Generate Docs Locally

To statically generate documentation locally, run:

bundle exec yard

You can then open the generated documentation at docs/index.html.

Test all Docs Examples

We use yard-doctest to test all code examples in our YARD documentation. To run doctest locally:

bundle exec yard doctest

We use dokaz to test all code examples in this README.md file, and all other non-code documentation. To run dokaz locally:

bundle exec dokaz

A Note on Testing

When testing memoized module methods, note that some testing setups will reuse the same instance (which includes/extends/prepends the module) across tests, which can result in confusing test failures when this differs from how you use the code in production.

For example, Rails view helpers are modules that are commonly tested with a shared view instance. Rails initializes a new view instance for each web request so any view helper methods would only be memoized for the duration of that web request, but in tests (such as when using rspec-rails's helper), the memoization may persist across tests. In this case, simply reset the memoization between your tests with something like:

after(:each) { helper.reset_memo_wise }

Further Reading

We presented at RubyConf 2021:

  • Achieving Fast Method Metaprogramming: Lessons from MemoWise (slides / benchmarks)

And we've written more about MemoWise in a series of blog posts:

Logo

MemoWise's logo was created by Luci Cooke. The logo is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/panorama-ed/memo_wise. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

Releasing

To make a new release of MemoWise to RubyGems, first install the release dependencies (e.g. rake) as follows:

BUNDLE_WITH=release bundle install

Then carry out these steps:

  1. Update CHANGELOG.md:

    • Add an entry for the upcoming version x.y.z
    • Move content from Unreleased to the upcoming version x.y.z
    • Update the diff links for this version and Unreleased in CHANGELOG.md
    • Change Unreleased section to say:
      **Gem enhancements:** none
      
      _No breaking changes!_
      
      **Project enhancements:** none
      
    • Commit with title Update CHANGELOG.md for x.y.z
  2. Update lib/memo_wise/version.rb

    • Replace with upcoming version x.y.z
    • Run bundle install to update Gemfile.lock
    • Commit with title Bump version to x.y.z
  3. bundle exec rake release

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the MemoWise project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

memo_wise's People

Contributors

alpaca-tc avatar annemirasol avatar ch-panorama avatar denisdefreyne avatar dependabot-preview[bot] avatar dependabot[bot] avatar estern1011 avatar eswidler avatar gogainda avatar honigc avatar jacobevelyn avatar jemmaissroff avatar jeremyevans avatar jordannb avatar liaden avatar mizhi avatar mrthewalrus avatar ms-ati avatar murshed-panorama avatar ngnasr1123 avatar parkerfinch avatar petehanner avatar pikachuexe avatar rbamos avatar seckenrode avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

memo_wise's Issues

Dependabot couldn't fetch the branch/reference for panolint

Your dependency file specified a branch or reference for panolint, but Dependabot couldn't find it at the project's source. Has it been removed?

For Ruby dependencies, this can be caused by a branch specified in your Gemfile being deleted at the source, or having been rebased, so the commit reference in your Gemfile.lock is no longer included in the branch. In that case, it can be fixed by running bundler update panolint locally.

View the update logs.

Randomize RSpec test ordering

Right now our test ordering is deterministic, which makes it more likely that we'll have hidden dependencies between tests. We should randomize our test ordering instead using RSpec's built-in mechanism for that.

Spec coverage for Modules which use MemoWise

Consider:

module Speak
  prepend MemoWise

  def speak
    "speaking!"
  end
  memo_wise :speak
end

class Person
   include Speak
  
   def initialize(word = "hey")
     @word = word
   end

   def talk
     @word
   end
end

p = Person.new("hello")

p.talk
#=> "hello"

p.speak
#=> "speaking!"

What happens when we include modules which are using MemoWise into other classes?

Bug: private method support in reset_memo_wise

Via @JacobEvelyn on Slack:

My branch to port Rainbow uncovered another MemoWise bug 🙂 This line should use respond_to?(method_name, true) instead of respond_to?(method_name) because by default it does not include private methods.

I don't have a ton of time this week—would either of you have time to make a fix?

Investigate case where ancestor chain gets reordered

We encountered a case in a complex app where we saw something like the following:

class ExampleClass
  include ExampleModule

  def example_method
    true
  end
  memo_wise :example_method
end

module ExampleModule
  prepend MemoWise
  extend ActiveSupport::Concern

  included do
    def example_method
      false
    end
  end
end

ExampleClass.new.example_method
# => true

And we noticed that if instead we have:

class ExampleClass
  prepend MemoWise # This line added
  include ExampleModule

  ...
end

# Now:
ExampleClass.new.example_method
# => false

but if we move the prepend MemoWise after the include ExampleModule we get the original behavior again. Something about calling prepend MemoWise first modifies the ancestor chain in a way I do not understand.

We should investigate and see if this is expected Ruby behavior, a bug in MemoWise, or a bug in the Ruby interpreter.

Use squiggly heredocs everywhere

Squiggly heredocs seem like the "more correct" way to use multiline strings for our use case, and they're supported in all Ruby versions we support, so we should switch to using them everywhere.

Rework the README and branding

  • update tagline in gemspec and on GitHub: The wise choice for Ruby memoization
  • add feature list/unique selling points of our gem:
    • Highest performance + Rigorous benchmarking
    • Preset
    • reset
    • Frozen instances (value objects)
    • Test coverage
    • Well-documented API
  • Add other GitHub badges (see examples here: https://github.com/JacobEvelyn/friends)

Consider optimizing small subset of zero-arity cases

There's a small subset of cases that I think we could optimize even further, depending on how crazy we want to get. If:

  • the method we're optimizing has an arity of 0 (no args)
  • the result object can be converted to Ruby source code with no loss of information, e.g. it is both frozen and one of: nil, a boolean, a small int (Fixnum?), a symbol, or a string
    • (or maybe an array or hash containing only those elements? what about nested arrays and hashes that are all frozen?)

Then when the method is first called, instead of inserting the value into our method's cache we can actually just rewrite the entire method to hardcode the result. This would make resetting/presetting challenging, and we'd have to be careful about which cases allow this, but I think it could work for some cases.

And since this logic would only occur on the first call, it should overall be a performance win (and in fact we'd only see the upside in our benchmarks). Would love thoughts @jemmaissroff @ms-ati

Explore using array as base data structure instead of hash

Microbenchmarking shows that array accesses are faster than hash accesses (which makes sense). The tricky thing is how to change e.g. the initialize method to set up something like @_memo_wise = [SENTINEL, {}, {}, SENTINEL, ...]

For no-args methods, we'd need to either:

  • use a sentinel value because otherwise we can't distinguish between not-memoized and memoized-as-nil, or
  • continue using a separate hash (e.g. @_memo_wise_no_args) instead of the array

NOTE: The reason we can't do something like:

def no_args
  return @memoize_no_args if defined?(@memoize_no_args)

  @memoize_no_args = super
end

is it doesn't work with frozen objects.

Support class method memoization

  • Should also support reset_memo_wise and preset_memo_wise on class methods

Note: one approach that might solve this and improve performance overall is using a different instance variable for each method instead of one big hash variable that we instantiate in initialize

Unused .travis.yml

We are using Github Actions rather than Travis CI, this config is stale and unused, isn't it?

Avoid defining methods unless needed

Similar to how we only define inherited when we need it, we should only define initialize and allocate where needed.

It would also be nice to do this for reset_memo_wise and preset_memo_wise as well—we don't always need them on both the class and instance level.

Error memo_wising in subclass with included module

require 'memo_wise'

class C1
  prepend MemoWise
  def method_one
    1
  end
  memo_wise :method_one
end

module M1
  prepend MemoWise
  def method_two
    2
  end
  memo_wise :method_two
end

class C2 < C1
  include M1
  def method_three
    3
  end
  memo_wise :method_three
end

Results in:

/Users/randy.stoller/.rvm/gems/ruby-3.0.2/gems/memo_wise-1.3.0/lib/memo_wise/internal_api.rb:219:in `class_variable_get': class variable @@_memo_wise_index_counter of M1 is overtaken by C1 (RuntimeError)
	from /Users/randy.stoller/.rvm/gems/ruby-3.0.2/gems/memo_wise-1.3.0/lib/memo_wise/internal_api.rb:219:in `next_index!'
	from /Users/randy.stoller/.rvm/gems/ruby-3.0.2/gems/memo_wise-1.3.0/lib/memo_wise.rb:181:in `memo_wise'
	from test.rb:24:in `<class:C2>'
	from test.rb:19:in `<main>'

This worked in 1.1.0 and only happens if the superclass, subclass and module all memo_wise something.

Refactor for understandability

Before reaching v1.0, we'd like to move from the original code layout of "one huge memo_wise.rb for the code, and another huge memo_wise_spec.rb for the tests", and towards a model of extracted code and specs that are individually more approachable and readable.

Dependabot couldn't find a gems.rb for this project

Dependabot couldn't find a gems.rb for this project.

Dependabot requires a gems.rb to evaluate your project's current Ruby dependencies. It had expected to find one at the path: /.overcommit/gems.rb.

If this isn't a Ruby project, or if it is a library, you may wish to disable updates for it in the .dependabot/config.yml file in this repo.

View the update logs.

Dependabot couldn't fetch the branch/reference for panolint

Your dependency file specified a branch or reference for panolint, but Dependabot couldn't find it at the project's source. Has it been removed?

For Ruby dependencies, this can be caused by a branch specified in your Gemfile being deleted at the source, or having been rebased, so the commit reference in your Gemfile.lock is no longer included in the branch. In that case, it can be fixed by running bundler update panolint locally.

View the update logs.

Multiple composition inheritance bug

module M1
  prepend MemoWise

  def method_to_memowise ; true ; end
  memo_wise :method_to_memowise
end

module M2
  prepend MemoWise

  def other_method_to_memowise ; false ; end
  memo_wise :other_method_to_memowise
end

class C1
  include M1, M2
end

c = C1.new
puts c.method_to_memowise # Should be true
# => true
puts c.other_method_to_memowise # Should be false
# => true

I'm not sure if there's an elegant way to fix this. The easy solutions I can think of are:

  • Use a global index counter instead of one tied to the class hierarchy [downside: objects will have large/sparse arrays storing results]
  • Go back to using hashes keyed on method name instead of arrays [downside: hash accesses are slightly slower than array accesses]

Some "more performant" options that may be difficult:

  • Have MemoWise delay the module_eval until either (a) the method is called the first time or (b) the object's freeze method is called.
  • Have MemoWise listen for changes to the ancestor chain and re-module_eval some/all methods to avoid index conflicts

Handle block arguments

  • Explicit block, raise an argument error at setup of memoization
  • Implicit block, passed in, ignore for the purposes of memoization?
    • Why: We’d have to check block_given? at every call, which is an unacceptable performance cost to ensure we are rejecting something we documented we don’t support
  • Document in API docs and README that we don’t support memoizing methods which take blocks
    • Could suggest as a workaround defining the method to take a proc arg as a parameter which we can memoize (add a spec here to verify this works as expected)
      • is this necessary?

Add test coverage

  • Include a badge in the README
  • set a minimum amount below which will trigger test failure
    • should this be an absolute amount or a relative change or both?

Use method-specific instance variables for methods that take arguments

This was a suggestion made by @sampersand in the RubyConf Discord channel for our recent talk. The idea is that instead of using @_memo_wise for all method types, we'd use a different instance variable for each method, like @_memo_wise_method_#{method_name}. This should save us an array lookup for a slight performance boost.

There are a few challenges:

  • we'd need to avoid name collisions, for example when memoizing both a data? and a data! and a data method
    • probably a simple .sub("?", "__qmark__").sub("!", "__bang__") would be sufficient
    • @jemmaissroff may have insight into whether there's a max instance variable length to be worried about (or a length at which performance decreases)
      • if so, we could instead use a scheme like @_memo_wise_method_#{counter} or use UUIDs, etc.
  • to support frozen objects we need to ensure that these instance variables are initialized to empty hashes before freezing
    • this could probably be done either in an overridden initialize or freeze method
  • this approach will not support resetting and presetting zero-arity methods on frozen objects
    • a simple path forward would be to continue using our array for zero-arity methods
    • are there good alternatives?

Would love discussion on this idea and its tradeoffs!

Add YARD documentation

  • Create the README badge+link, and setup automatic generation on RubyDoc.info for both Github project and (later) published gem
  • Instrument the project’s development dependencies so that we can generate docs exactly like RubyDoc.info locally
  • Backfill docs so that they look good on RubyDoc.info
  • Backfill docs for meta-programmed methods as well
  • Take a pass to ensure the YARD linking (to classes and methods) is working
  • Ensure code snippets are rendered with Ruby highlighting
  • Try to add test coverage of documentation example code snippets, e.g. with https://github.com/p0deje/yard-doctest

Broken handling of inheritance after "Optimize zero-argument methods"

Seem that after d768e10 inheritance is not handled properly, because used indices are mixed up between classes.

require 'memo_wise'

class Parent
  prepend MemoWise

  def bar
    'bar'
  end
  memo_wise :bar
end

class Child < Parent
  def foo
    'foo'
  end
  memo_wise :foo
end

child = Child.new
pp child.foo # initialize Child sentinels
pp child.bar # expected 'bar', got 'foo' because Child sentinels used instead of Parent

Investigate whether we can speed up tests

Many of our tests run quite quickly (as they should, since they simply execute pure Ruby), but many are quite slow (as in: >1s per test). The slow ones seem to be within when defined with scope 'class << self' and when defined with scope 'module << self' context blocks, though there may be others I've missed.

Test time currently is a big nuisance when working on features/and speeding up tests would be a big quality-of-life improvement.

Add logo!

Perhaps an owl holding a ruby?

Logo should appear at the top of README.md

Tidy CHANGELOG

Our CHANGELOG has some inconsistent formatting that could be improved. For example:

  • Tenses: do we use fix or fixed or fixes?
  • Subheadings: should all versions use the same subheadings?
  • PR links: can we link to associated PRs as mentioned in #241 (review) ?
  • Diff links: is it clearer to put diff links inline instead of at the bottom of the doc? (I recently released a new version and didn't realize I needed to add that so the link was broken.)

Method arguments with specific names can be overwritten

For a method like the following:

def foo(arg1, key)
  ...
end

MemoWise will produce a method like the following:

def foo(arg1, key)
  ...
  key = [arg1, key] # Overrides key!
end

Similar bugs are possible for methods with arguments that share names with other variables we use, like output or hash.

We should fix all methods to only use _memo_wise_-prefixed variables.

Create easier way to compare changes against current `MemoWise` version

When working on changes with (potentially) very small performance gains, it's hard to know whether the changes are actually an improvement. Currently, my process is:

  1. Change my local branch (with the changes I want to test) to have a new name for MemoWise. This is annoying and requires changing a surprisingly large number of file names, directory names, and lines of code.
  2. Change the benchmarks script and Gemfile to load MemoWise from the latest commit on GitHub, in addition to the local changes (which now have a new name).
  3. Try to run benchmarks locally but uncover issues with (1) and (2) that I missed.
  4. Fix those issues.
  5. Run benchmarks locally with as "minimal" of an environment as I can manage.
  6. Re-run benchmarks multiple times to confirm results are consistent.

I would love if (for starters) our GitHub Action could somehow compare the current change against the latest commit on main. There's lots of ways we could make this more featureful (e.g. only run this benchmark if a label is set in the PR, and run benchmarks for a longer time than usual for this comparison), but anything that's better than the above would be a huge improvement.

Support reflection on memoized method parameters

In Panorama spec helper code 'initialized_double', we observed
that MemoWise interrupts the use of Ruby reflection on method
parameters, by replacing with delegating methods that have overly
generic signatures, like:

    def initialize(...)

    def slow_method(*arg, **kwargs)

When we run for example TestClass.instance_method(:initialize).parameters, we get back those generic signatures, rather than the specific parameters that the original methods had.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.