lsegal / yard

YARD is a Ruby Documentation tool. The Y stands for "Yay!"
http://yardoc.org
MIT License
1.95k stars 398 forks source link

Remove OpenStruct #1525

Closed bkuhlmann closed 10 months ago

bkuhlmann commented 10 months ago

Hello. :wave: I'm seeing performance warnings show up when using YARD in Ruby 3.3.0 due to heavy use of OpenStruct which the Ruby core team no longer recommends being used.

Steps to reproduce

You'll want to ensure that performance warnings are enabled. Example:

export RUBYOPT="-W:performance"

With the above in place, now you can recreate using either of the following:

`yard doc --no-private` ``` /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/templates/template_options.rb:34: warning: OpenStruct use is discouraged for performance reasons /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/parser/source_parser.rb:365: warning: OpenStruct use is discouraged for performance reasons /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/templates/template_options.rb:34: warning: OpenStruct use is discouraged for performance reasons Files: 0 Modules: 0 ( 0 undocumented) Classes: 0 ( 0 undocumented) Constants: 0 ( 0 undocumented) Attributes: 0 ( 0 undocumented) Methods: 0 ( 0 undocumented) 100.00% documented ``` Notice the performance warnings shown above.
`gem install faker` ``` Successfully installed faker-3.2.2 Building YARD (yri) index for faker-3.2.2... /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/templates/template_options.rb:34: warning: OpenStruct use is discouraged for performance reasons /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/parser/source_parser.rb:365: warning: OpenStruct use is discouraged for performance reasons /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/parser/source_parser.rb:365: warning: OpenStruct use is discouraged for performance reasons /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/handlers/processor.rb:101: warning: OpenStruct use is discouraged for performance reasons /Users/bkuhlmann/.cache/frum/versions/3.3.0/lib/ruby/gems/3.3.0/gems/yard-0.9.34/lib/yard/docstring_parser.rb:90: warning: OpenStruct use is discouraged for performance reasons ``` Notice the performance warnings displayed when installing the gem. What's shown above is only a small example, this actually repeats for several pages.

Actual Output

See above.

Expected Output

I would expect to see no performance warnings due to the removal of OpenStruct since it has terrible performance. If you need benchmarks, check out how OpenStruct performs ~4,000 times worse than a Struct or Data object in these benchmarks.

Environment details:

lsegal commented 10 months ago

In order to consider this a defect worth fixing (and possibly causing a backwards incompatible change to the API), there would need to be more-than-theoretical / real world data from YARD usage. As shown later in this post, I cannot reproduce such a slowdown. It's worth noting that the slow OpenStruct behavior is in first-write on an attribute in newly-allocated struct objects. Below is a much more comprehensive benchmark of the behavior, much more complete than the benchmarks linked above and illustrates the real issue.

Complete OpenStruct benchmarks ### Benchmark code: ```ruby require 'benchmark' require 'ostruct' n = 100000 class MyStruct < Struct.new(:a, :b, :c); end ostruct = OpenStruct.new mystruct = MyStruct.new Benchmark.bmbm do |x| x.report("OpenStruct.new(args)") { n.times { OpenStruct.new a: 1, b: 2, c: 3 } } x.report("Struct.new(args)") { n.times { MyStruct.new 1, 2, 3 } } x.report("OpenStruct.new (blank)") { n.times { OpenStruct.new } } x.report("OpenStruct (assign)") { n.times { ostruct.a = 1 } } x.report("Struct (assign)") { n.times { mystruct.a = 1 } } x.report("OpenStruct (read)") { n.times { ostruct.a } } x.report("Struct (read)") { n.times { mystruct.a } } end ``` ### Results: ``` Rehearsal ---------------------------------------------------------- OpenStruct.new(args) 1.469000 0.016000 1.485000 ( 1.477508) Struct.new(args) 0.031000 0.000000 0.031000 ( 0.017986) OpenStruct.new (blank) 0.016000 0.000000 0.016000 ( 0.017839) OpenStruct (assign) 0.016000 0.000000 0.016000 ( 0.012422) Struct (assign) 0.000000 0.000000 0.000000 ( 0.004586) OpenStruct (read) 0.015000 0.000000 0.015000 ( 0.010191) Struct (read) 0.000000 0.000000 0.000000 ( 0.004275) ------------------------------------------------- total: 1.563000sec user system total real OpenStruct.new(args) 1.485000 0.000000 1.485000 ( 1.471337) Struct.new(args) 0.015000 0.000000 0.015000 ( 0.016888) OpenStruct.new (blank) 0.016000 0.000000 0.016000 ( 0.018453) OpenStruct (assign) 0.015000 0.000000 0.015000 ( 0.011916) Struct (assign) 0.000000 0.000000 0.000000 ( 0.004551) OpenStruct (read) 0.016000 0.000000 0.016000 ( 0.009480) Struct (read) 0.015000 0.000000 0.015000 ( 0.004149) ```

In practice, YARD doesn't really go through this "slow" code path often because most OpenStruct objects long-lived and shared, so first-write of attributes is fairly rare (we use structs to store shared state while parsing entire source trees creating maybe only a handful of structs). There are a few edge cases here-- if you use a lot of macros / directives, for example, you could see an impact in performance.

That said, changing this would be a breaking change to YARD's API, since OpenStructs are part of the public extension API in order to allow developers to provide custom context while parsing. It is not possible to exchange OpenStruct with Struct/Data-- they simply do not do the same thing. In order to provide similar (but not exactly equal) support to OpenStruct, we would need to implement a custom OpenStruct class that basically does the same thing as OpenStruct itself. I wrote up a quick example of this as YARD::OpenStruct and added it to YARD's codebase (with passing tests) and the change was not detectable in a full run of yard on YARD's own directory structure:

# CURRENT BEHAVIOR
$ time ruby bin/yard 
Files:         186
Modules:        49 (    6 undocumented)
Classes:       209 (   41 undocumented)
Constants:      98 (   29 undocumented)
Attributes:    179 (    0 undocumented)
Methods:       997 (  136 undocumented)
 86.16% documented

real    0m15.722s
user    0m0.015s
sys     0m0.000s

# AFTER YARD::OpenStruct patch:
$ time ruby bin/yard 
Files:         186
Modules:        49 (    6 undocumented)
Classes:       209 (   41 undocumented)
Constants:      98 (   29 undocumented)
Attributes:    179 (    0 undocumented)
Methods:       997 (  136 undocumented)
 86.16% documented

real    0m15.653s
user    0m0.000s
sys     0m0.000s

Note that the above is just one sample, the averages range across the board and the tl;dr is that this change would not improve performance in any noticeable way. For reference, the YARD::OpenStruct implementation is provided below with benchmarks to compare to OpenStruct/Struct:

Full YARD::OpenStruct benchmarks ### Implementation / benchmark code: ```ruby require 'benchmark' require 'ostruct' module YARD # An OpenStruct compatible struct class that allows for basic access of attributes # via +struct.attr_name+ and +struct.attr_name = value+. class OpenStruct def initialize(hash = {}) hash.each { |k, v| instance_variable_set("@#{k}", v) } end # @private def method_missing(name, *args) if name =~ /=$/ instance_variable_set("@#{name.to_s[0..-2]}", args.first) else instance_variable_get("@#{name.to_s}") end end def to_h instance_variables.each_with_object({}) do |var, hash| hash[var.to_s[1..-1].to_sym] = instance_variable_get(var) end end def ==(other) other.is_a?(self.class) && to_h == other.to_h end end end n = 100000 class MyStruct < Struct.new(:a, :b, :c); end ostruct = OpenStruct.new yostruct = YARD::OpenStruct.new mystruct = MyStruct.new Benchmark.bmbm do |x| x.report("Struct.new(args)") { n.times { MyStruct.new 1, 2, 3 } } x.report("Struct (assign)") { n.times { mystruct.a = 1 } } x.report("Struct (read)") { n.times { mystruct.a } } x.report("OpenStruct.new(args)") { n.times { OpenStruct.new a: 1, b: 2, c: 3 } } x.report("OpenStruct.new (blank)") { n.times { OpenStruct.new } } x.report("OpenStruct (assign)") { n.times { ostruct.a = 1 } } x.report("OpenStruct (read)") { n.times { ostruct.a } } x.report("YARD::OpenStruct.new(args)") { n.times { YARD::OpenStruct.new a: 1, b: 2, c: 3 } } x.report("YARD::OpenStruct.new (blank)") { n.times { YARD::OpenStruct.new } } x.report("YARD::OpenStruct (assign)") { n.times { yostruct.a = 1 } } x.report("YARD::OpenStruct (read)") { n.times { yostruct.a } } end ``` ### Results: ``` Rehearsal ---------------------------------------------------------------- Struct.new(args) 0.031000 0.000000 0.031000 ( 0.019441) Struct (assign) 0.000000 0.000000 0.000000 ( 0.005331) Struct (read) 0.000000 0.000000 0.000000 ( 0.004483) OpenStruct.new(args) 1.578000 0.000000 1.578000 ( 1.547479) OpenStruct.new (blank) 0.032000 0.000000 0.032000 ( 0.018671) OpenStruct (assign) 0.015000 0.000000 0.015000 ( 0.013305) OpenStruct (read) 0.047000 0.000000 0.047000 ( 0.009657) YARD::OpenStruct.new(args) 0.110000 0.000000 0.110000 ( 0.106746) YARD::OpenStruct.new (blank) 0.015000 0.000000 0.015000 ( 0.019997) YARD::OpenStruct (assign) 0.078000 0.000000 0.078000 ( 0.086861) YARD::OpenStruct (read) 0.016000 0.000000 0.016000 ( 0.004378) ------------------------------------------------------- total: 1.922000sec user system total real Struct.new(args) 0.015000 0.000000 0.015000 ( 0.017003) Struct (assign) 0.000000 0.000000 0.000000 ( 0.004562) Struct (read) 0.000000 0.000000 0.000000 ( 0.004176) OpenStruct.new(args) 1.500000 0.000000 1.500000 ( 1.510916) OpenStruct.new (blank) 0.031000 0.000000 0.031000 ( 0.017657) OpenStruct (assign) 0.000000 0.000000 0.000000 ( 0.011936) OpenStruct (read) 0.016000 0.000000 0.016000 ( 0.009653) YARD::OpenStruct.new(args) 0.093000 0.000000 0.093000 ( 0.107001) YARD::OpenStruct.new (blank) 0.016000 0.000000 0.016000 ( 0.019866) YARD::OpenStruct (assign) 0.016000 0.000000 0.016000 ( 0.004695) YARD::OpenStruct (read) 0.000000 0.000000 0.000000 ( 0.004348) ```

As you can see, the naive implementation I threw together in a few minutes fixes the fundamental allocation speed issue-- but it changes the API. YARD extension authors who were relying on various OpenStruct methods would start seeing failures, and in order to make this backward compatible, YARD would be on the hook for implementing a drop-in replacement to OpenStruct just to fix an allocation performance issue which could have been patched in OpenStruct itself.

This gets to the heart of the issue: the idea that OpenStruct "is slow" seems like an entirely bogus claim based on bad data. I could be off base here since I haven't investigated the current OpenStruct implementation too closely, but I was able to throw together a reasonably performant replacement in a few minutes, it seems entirely probable that OpenStruct's own allocation issue could have been fixed instead of providing the warning that the Ruby core team chose to add.

I would say that this should be marked as an upstream defect. Why would we just fix this in YARD when the Ruby team could be addressing the performance issue and fixing it for everybody? Especially given that in practice, this does not affect actual performance in YARD, but likely does in other Ruby applications.

bkuhlmann commented 10 months ago

Thanks and apologies for not clarifying the kind of benchmarks I used (I have fixed this and will be publishing additional details in the next release of my Benchmarks project). Regardless, the difference in what I reported and what you reported has to do with YJIT enabled or disabled. Using a modified version of what you originally published, the following highlights the difference:

With YJIT **Benchmark** ``` ruby #! /usr/bin/env ruby # frozen_string_literal: true # Save as `demo`, then `chmod 755 demo`, and run as `./demo`. require "bundler/inline" gemfile true do source "https://rubygems.org" gem "amazing_print" gem "debug" end Warning[:performance] = false require "benchmark" require "ostruct" MAX = 10_0000 StructDemo = Struct.new :a, :b, :c ostruct = OpenStruct.new struct = StructDemo.new puts "#{RUBY_DESCRIPTION}" Benchmark.bmbm do |benchmark| benchmark.report("OpenStruct.new(args)") { MAX.times { OpenStruct.new a: 1, b: 2, c: 3 } } benchmark.report("Struct.new(args)") { MAX.times { StructDemo[1, 2, 3] } } benchmark.report("OpenStruct.new (blank)") { MAX.times { OpenStruct.new } } benchmark.report("OpenStruct (assign)") { MAX.times { ostruct.a = 1 } } benchmark.report("Struct (assign)") { MAX.times { struct.a = 1 } } benchmark.report("OpenStruct (read)") { MAX.times { ostruct.a } } benchmark.report("Struct (read)") { MAX.times { struct.a } } end ``` **Results** ``` ruby 3.3.0 (2023-12-25 revision 5124f9ac75) +YJIT [arm64-darwin22.6.0] Rehearsal ---------------------------------------------------------- OpenStruct.new(args) 3.559108 23.286580 26.845688 ( 26.845435) Struct.new(args) 0.025189 0.000576 0.025765 ( 0.025803) OpenStruct.new (blank) 0.171071 16.393790 16.564861 ( 16.570786) OpenStruct (assign) 0.005980 0.000376 0.006356 ( 0.006378) Struct (assign) 0.002841 0.000218 0.003059 ( 0.003058) OpenStruct (read) 0.006043 0.000337 0.006380 ( 0.006379) Struct (read) 0.002913 0.000171 0.003084 ( 0.003083) ------------------------------------------------ total: 43.455193sec user system total real OpenStruct.new(args) 0.962299 0.012276 0.974575 ( 0.975092) Struct.new(args) 0.009832 0.000026 0.009858 ( 0.009868) OpenStruct.new (blank) 0.163269 16.516638 16.679907 ( 16.681001) OpenStruct (assign) 0.005864 0.000013 0.005877 ( 0.005874) Struct (assign) 0.002835 0.000009 0.002844 ( 0.002842) OpenStruct (read) 0.006415 0.000021 0.006436 ( 0.006512) Struct (read) 0.002620 0.000001 0.002621 ( 0.002618) ```
Without YJIT **Benchmark** ``` ruby #! /usr/bin/env ruby --yjit-disable # frozen_string_literal: true # Save as `demo`, then `chmod 755 demo`, and run as `./demo`. require "bundler/inline" gemfile true do source "https://rubygems.org" gem "amazing_print" gem "debug" end Warning[:performance] = false require "benchmark" require "ostruct" MAX = 10_0000 StructDemo = Struct.new :a, :b, :c ostruct = OpenStruct.new struct = StructDemo.new puts "#{RUBY_VERSION}" Benchmark.bmbm do |benchmark| benchmark.report("OpenStruct.new(args)") { MAX.times { OpenStruct.new a: 1, b: 2, c: 3 } } benchmark.report("Struct.new(args)") { MAX.times { StructDemo[1, 2, 3] } } benchmark.report("OpenStruct.new (blank)") { MAX.times { OpenStruct.new } } benchmark.report("OpenStruct (assign)") { MAX.times { ostruct.a = 1 } } benchmark.report("Struct (assign)") { MAX.times { struct.a = 1 } } benchmark.report("OpenStruct (read)") { MAX.times { ostruct.a } } benchmark.report("Struct (read)") { MAX.times { struct.a } } end ``` **Results** ``` ruby 3.3.0 (2023-12-25 revision 5124f9ac75) +YJIT [arm64-darwin22.6.0] Rehearsal ---------------------------------------------------------- OpenStruct.new(args) 0.976907 0.023529 1.000436 ( 1.000522) Struct.new(args) 0.024205 0.000461 0.024666 ( 0.024734) OpenStruct.new (blank) 0.018084 0.000329 0.018413 ( 0.018414) OpenStruct (assign) 0.005691 0.000024 0.005715 ( 0.005714) Struct (assign) 0.002856 0.000002 0.002858 ( 0.002857) OpenStruct (read) 0.006092 0.000102 0.006194 ( 0.006194) Struct (read) 0.002541 0.000042 0.002583 ( 0.002583) ------------------------------------------------- total: 1.060865sec user system total real OpenStruct.new(args) 0.943713 0.020637 0.964350 ( 0.964416) Struct.new(args) 0.009694 0.000084 0.009778 ( 0.009776) OpenStruct.new (blank) 0.014508 0.000166 0.014674 ( 0.014673) OpenStruct (assign) 0.005711 0.000088 0.005799 ( 0.005796) Struct (assign) 0.002836 0.000014 0.002850 ( 0.002849) OpenStruct (read) 0.005891 0.000031 0.005922 ( 0.005919) Struct (read) 0.002563 0.000008 0.002571 ( 0.002570) ```

The key differences between the two benchmarks are:

In practice, YARD doesn't really go through this "slow" code path often because most OpenStruct objects long-lived and shared, so first-write of attributes is fairly rare

Fair. Yet, the Ruby core team recommends not using OpenStruct at all and with the new performance warning category in Ruby 3.3.0 they are making this even more apparent by raising these performance warnings now. I never use OpenStruct in production code for this very reason because it's too easy to forget this fact. That said, there a a couple potential alternatives:

Why would we just fix this in YARD when the Ruby team could be addressing the performance issue and fixing it for everybody?

Fair except the Ruby core team hasn't been promoting the use of OpenStruct for multiple years and with the release of Ruby 3.3.0 performance warnings, they are getting more aggressive about this. I don't seen the Ruby core team addressing or wanting to fix this since they are trying to steer folks away from OpenStruct usage.

Anyway, these are some thoughts and wanted to bring this to your attention for consideration. :bow:

lsegal commented 10 months ago

I cannot reproduce the same results with blank OpenStruct.new allocation in Ruby 3.2.2:

▶ bundle exec ruby --yjit bench.rb
ruby 3.2.2 (2023-03-30 revision e51014f9c0) +YJIT [arm64-darwin22]
...
                             user     system      total        real
OpenStruct.new(args)     3.331943  33.654262  36.986205 ( 37.526594)
Struct.new(args)         0.012822   0.000427   0.013249 (  0.013376)
OpenStruct.new (blank)   0.011470   0.000457   0.011927 (  0.012022)
OpenStruct (assign)      0.009909   0.000621   0.010530 (  0.010548)
Struct (assign)          0.003504   0.000481   0.003985 (  0.004172)
OpenStruct (read)        0.007868   0.000574   0.008442 (  0.008476)
Struct (read)            0.002935   0.000368   0.003303 (  0.003367)

Seems to me as though you've found a regression in YJIT that should be reported to the Ruby team. The fact that an arg-less call to OpenStruct.new does absolutely nothing magical and performs as bad as you've shown should be a big hint that the issue is not with OpenStruct, but YJIT itself.

This is confirmed by testing the benchmark via JRuby (with JIT) which performs absolutely normally here, indicating that there's nothing inherent to OpenStruct's implementation that should be incompatible with JIT, and, frankly, nothing besides initial dynamic access should be slow with or without JIT.

▶ jruby bench.rb                  
jruby 9.4.5.0 (3.1.4) 2023-11-02 1abae2700f OpenJDK 64-Bit Server VM 21.0.1 on 21.0.1 +jit [arm64-darwin]
Rehearsal ----------------------------------------------------------
OpenStruct.new(args)     2.200000   0.040000   2.240000 (  0.719111)
Struct.new(args)         0.110000   0.010000   0.120000 (  0.034137)
OpenStruct.new (blank)   0.050000   0.000000   0.050000 (  0.017374)
OpenStruct (assign)      0.100000   0.000000   0.100000 (  0.031827)
Struct (assign)          0.020000   0.000000   0.020000 (  0.006671)
OpenStruct (read)        0.060000   0.000000   0.060000 (  0.018253)
Struct (read)            0.020000   0.000000   0.020000 (  0.005145)
------------------------------------------------- total: 2.610000sec

                             user     system      total        real
OpenStruct.new(args)     0.650000   0.020000   0.670000 (  0.351861)
Struct.new(args)         0.130000   0.000000   0.130000 (  0.044659)
OpenStruct.new (blank)   0.020000   0.000000   0.020000 (  0.008069)
OpenStruct (assign)      0.020000   0.010000   0.030000 (  0.007613)
Struct (assign)          0.020000   0.000000   0.020000 (  0.005670)
OpenStruct (read)        0.020000   0.000000   0.020000 (  0.007196)
Struct (read)            0.010000   0.000000   0.010000 (  0.001456)

JRuby's JIT can handle this library just fine. Looks like YJIT has a long way to go.

Going to mark this as closed since any changes would likely create backward incompatible API changes to YARD, and the usage patterns YARD uses with OpenStruct should not cause any serious performance concerns unless you are using a defective version of YJIT.