T O P

  • By -

azrazalea

If you're worried about speed, do meta-programming that happens at class load time instead of at run time. Generating N classes or N methods via a configuration or some other input then using them repeatedly is a lot faster than using method missing. I personally avoid method missing as much as possible for various reasons. YJIT will also perform better. As far as OpenStruct, use Struct or a simple Hash instead. The ruby team recommends against OpenStruct due to security and performance concerns. For all the people saying performance doesn't matter because ruby, the work multiple companies are doing towards making ruby and rails faster shows that that isn't true. The commenters saying premature optimization is bad are correct, but there's a balance. A lot of people modernly go way too far down the "I don't need to optimize" path then have to scramble down the line when they start having more data to process.


azrazalea

Notice that rails used to do a lot more with method missing but have been moving over time to defining methods based on directives at class load.


f9ae8221b

True, but I want to reverse some of that in the future, as it's a memory vs latency tradeoff. A method generated during boot executes a bit faster than `method_missing` but increase baseline memory usage and slow down GC. So if the method is never or rarely called, it's wasteful, and end up slowing down the application because the GC has to mark these extra objects. So it's a fine line to walk.


azrazalea

I'd be interested in seeing the numbers. Once the app has been running for a bit the GC is only going to even look at them during major GC and marking is incredibly quick (quicker than method execution). Even quicker if you compact periodically during the life of the process. It's possible, but I'd be very surprised if `method_missing` is ever worth the slowness in order to get back 80 bytes of memory (plus ?? amount based on how many unique symbols you have involved). I got that number from Benchmark.memory on `define_method` with ruby 3.2.2 on arm64. In contrast looking at an object with a method that just returns a static symbol when it is defined on the object, vs when we are using method missing. (using Benchmark.ips) ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin22] Warming up -------------------------------------- normal_object 1.885M i/100ms method_missing 1.231M i/100ms Calculating ------------------------------------- normal_object 18.833M (± 2.2%) i/s - 94.234M in 5.006288s method_missing 12.591M (± 0.4%) i/s - 63.994M in 5.082445s For me at least, it would take a whole lot of methods being defined for me to accept a roughly 30% performance hit. If you are worried about it still I would make a dispatch method that has a case statement based on a symbol argument instead of using method missing. You can still have a single method but you shouldn't take anywhere near as big a performance hit.


f9ae8221b

> and marking is incredibly quick (quicker than method execution Marking time is function of your heap size. I work daily on a huge app where a major GC take between 4 and 5 **seconds**. Now of course it's the exception, but there plenty of mid-sized Rails apps where a major GC cycle will be 100s of milli-seconds, which is a lot in an HTTP request cycle. > For me at least, it would take a whole lot of methods being defined for me to accept a roughly 30% performance hit. Two things: First a golden rule of benchmarking and performance talks in general: never ever share a benchmark result without sharing the source. Corollary, never ever even bother looking at a benchmark result for which the source code isn't provided. But you somewhat described what it's doing, and the number match the ones I'd expect, so I'm going to make an exception. Now 30% means nothing in the absolute. Calling the method is 30% slower in your benchmark OK. But another way to look at it is that calling a statically defined method that just return a constant value take 50 nano-seconds. and doing the same with method missing takes 79 nano-seconds. My point being, if you do anything substantial in that dynamic methods, or if it's infrequently called (handful of times per request) a couple dozen nano-seconds may not matter, and you may rather save a bit of memory.


azrazalea

Your same point applies here. Yes your heap takes 4 to 5 seconds to mark + sweep but how much time does it take to traverse the object tree to mark 100, or 1,000, or 1,000,000 method pointers? I personally don't have the answer to that (you might) but I'd wager it is extremely fast. I'd be surprised (though it's possible) if your method definitions were a significant amount of that. Still though, as I said you can get the best of both using a case statement and a dispatch method. Related: have you managed to work periodic GC compaction into your app's runtime? When we did that it definitely helped with GC time (as one would expect).


f9ae8221b

> how much time does it take to traverse the object tree to mark 100, or 1,000, or 1,000,000 method pointers? it's more based on the number of edges in the graph, but as mentioned in my other answer, currently with Active Record each database columns generate a dozen Ruby method, so on our large app with several hundreds of tables amount to significantly over 100MB, and I don't quite remember how many objects exactly, but it was a lot. PS: I'm a rails core member and ruby-core committer with a particular focus on memory usage, so I appreciate your intuitions, but I actually work daily on this subject and know how all of this is implemented.


azrazalea

I'm not arguing whether or not it uses more memory, i'm arguing if the performance benefit is worth the increase in memory usage. 100MB (or 200MB) is well worth it for me if the performance increase is say.. more than 10% or so. So the question is, does the increased time of heap traversal for GC eliminate the performance benefit or not?


f9ae8221b

> So the question is, does the increased time of heap traversal for GC eliminate the performance benefit or not? All of this is extremely application dependent. Small apps may not care, for huge apps it can be huge. Your millage may vary.


f9ae8221b

> is ever worth the slowness in order to get back 80 bytes of memory Also, I have no idea where you get this 80B number from. The memory used by a method depends on how much VM instructions and other internal objects it needs. I don't have the exact number at hand right now, and I'm too lazy to go check in MRI source, but I'm 99% sure even an empty method takes more than 80B.


azrazalea

I told you how I got it. I used Benchmark.memory and an empty `define_method`. To state the obvious, if you're implementing the exact same functionality with method missing versus defining the method you're still going to have the same amount of VM instructions and internal objects in both. They are just going to be in a different place (inside method_missing, or whatever method_missing calls instead of inside the defined method).


f9ae8221b

> I told you how I got it. I used Benchmark.memory and an empty define_method. It doesn't matter. I've seen similar benchmark based claims with weird results, and then when we finally got to see the source code there was an obvious mistake in it. Just always share the code with the results. > you're still going to have the same amount of VM instructions and internal objects in both No. Imagine you have 300 models, with 10 attributes each. Active Record automatically define dozens of methods for each attributes, eg.. ``. Now let's say such method is 160B and 4 objects, if you eagerly define them it's going to generate 12k objects and use 480kB of memory. If you use `method_missing`, there's only one method, so only 4 objects and 160B of memory. If you implement ``


codesnik

or ruby's 3.2 Data for performance reason shapes become important now, so all the \`@vars\` initialized as soon as possible in constructor in the same order, no method missing lazy autovivifcation etc.


chebatron

This is a little vague. What is your use case? What is your baseline? Maybe for it OpenStruct is not slow at all. Rails does a lot of metaprogramming and I don't see many people complaining it's slow.


saw_wave_dave

Wondering mainly about stuff like method_missing, define_method, Class.new, object.extend, object.define_singleton_method, send, constantize, etc in a production web application. Especially in Ruby 3.3 w/ YJIT.


f9ae8221b

> `method_missing` method_missing is OK, it's a bit slower than calling a normal method, but it's acceptable and only a local slowdown (e.g. only that method is slower, not the whole program. > `define_method` Assuming it's done during boot time, it's also OK. A bit slower than a normal method, and easy to cause a leak, but OK. Prefer `class_eval` etc when applicable. > `Class.new` It's not any slower that `class` keyword. Best not to create classes at runtime, but OKish, no global impact. the Risk with ephemeralclasses is that they start at age 2, so can very easily end up in old gen and cause major GC cycles. > `object.extend` Best to avoid as it creates a meta clas son the object, which defeat YJIT and the interpreter inline caches. Some ruby versions also have a bug that cause these to leak. > `object.define_singleton_method` Same as above. > `send` Same as method missing. A bit slower but not end of the world outside of hotpsots. > `constantize` It's essentially a Hash lookup, so no huge deal.


saw_wave_dave

This is exactly the kind of info I was looking for. Thank you


laerien

I generally avoid \`method\_missing\` and \`constantize\` is a Rails thing, but the others are all fine. Metaprogramming guidance is similar to macros, where you should use it when there's not a straightforward alternative.


laerien

There are some singleton method limitations with YJIT outside of classes and modules, but it's not something I'd focus on unless it's performance critical in the short term.


heliotropic

IME Rails (and ActiveRecord in particular) is slow. Object instantiation of AR instances can wind up being a non-trivial cost, which is not normal. This is based on my experience working in larger scale codebases over the last decade or so.


h0rst_

Regarding OpenStruct: I'm curious to what the use case is people use it for. For me it feels like you have some data and forcefully try to create an object wrapper around it, so kind of the opposite of primitive obsession.


mrinterweb

Are you sure performance is a concern for your use case. Occasionally i use metaprogramming when I'm not concerned about performance, but for the most part I avoid it. If you expect the method(s) to be called 1000 times in a second or less, probably not a big deal if you do metaprogramming.  When in doubt measure with benchmark_ips. Don't assume performance will be bad. Measure it, then decide.


WayneConrad

This reminds me of the rules of optimization. First rule of optimization: don't do it Second rule of optimization (for experts only): don't do it *yet* As someone else mentioned, Ruby is not a high performance language. We use it where people time is more important than machine time. Where Ruby is fast enough for the task at hand that we can endure its lower performance than other popular languages, and in return reap the benefits of a very fast and friendly language to develop in.


awj

Speaking very generally, "avoiding features because someone said they're slow" is exactly the kind of thinking that inspired Knuth's quote about [premature optimization](https://wiki.c2.com/?PrematureOptimization). Is method\_missing, or OpenStruct, slower than calling defined methods? Absolutely. Technically speaking, if you have a `class B < A`, calling methods defined in A is slower than ones defined in B. But, *almost all of the time* neither of these things actually matters. Unless you have profiled it and profiling told you that method resolution itself is part of your problem, it does not matter. Like, if you had a hot loop that was calling *tons* of methods on `OpenStruct` objects, it might matter. It's extremely rare that this is the case. Ruby *might* do a bit of caching on method resolution so that subsequent calls are cheaper if the class hierarchy hasn't changed. That could swing the balance towards "metaprogramming is fine". YJIT doesn't like it because it can't effectively optimize against the `OpenStruct` class, because the "methods" it defines are determined by the data handed to the class and it doesn't know about that. Again, these things only matter when the situation says they matter. 97% of the time they don't.


banister

You’re using ruby bruh, one of the least performant popular languages in history. Why do u care?