-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Optimise boilerplate generators, use instance constructors #3871
Conversation
Hmm I still see classes generated - is that expected? |
TLDR - it's good for Scala 3, Scala 2 is deceiving itself into generating a class file 😭 See scala/scala3#5928 |
As an alternative - use |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is worth doing but I do wonder if we can make it a bit more principled by using InvariantFunctor, ContravariantCartesian, or similar typeclasses on these kernel typeclasses (typeclasses can have typeclasses too!)
/** | ||
* Create a `CommutativeGroup` instance from the given inverse and combine functions and empty value. | ||
*/ | ||
@inline def instance[A](emp: A, inv: A => A, cmb: (A, A) => A): CommutativeGroup[A] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if instead we make an instance of InvariantFunctor[CommutativeGroup] and then we use the InvariantFunctor instances in the tuple code Gen?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You probably mean InvariantSemigroupal
so that we can use tupledN
right? That's doable but it would mean more allocations and reduced performance at runtime (going from (a, b, c, d)
to (a, (b, (c, d)))
and back) so I'm not sure that would be acceptable for cats-kernel
which is also used by algebra
.
Also optimized |
Thanks for being on top of this! |
@@ -115,4 +115,13 @@ trait RingFunctions[R[T] <: Ring[T]] extends AdditiveGroupFunctions[R] with Mult | |||
|
|||
object Ring extends RingFunctions[Ring] { | |||
@inline final def apply[A](implicit ev: Ring[A]): Ring[A] = ev | |||
|
|||
private[algebra] def instance[A](z: A, o: A, neg: A => A, add: (A, A) => A, mul: (A, A) => A): Ring[A] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
where are these private[algebra]
but it seems the others are @inline
. Is there a reason they aren't all the same?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cats already has public instance
methods for some (but not all) type classes and they are @inline
whereas Algebra doesn't have any. I just tried to follow the conventions of each project, but I think it doesn't hurt to add the annotation in Algebra too.
I don't know why the "Microsite" job is failing but I'm quite sure it's not related to this PR |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The build failure could be related to #3974. I'll bring it up there.
Make it a bit more readable by formatting constraints on a new line.
Due to limitations in Scala 2 SAM types often end up generating classes after all. `instance` constructors don't suffer from this issue and also let us handle type classes with multiple abstract methods.
Personally, I'm not convinced we need or even want this change. Apologies if I'm missing the point.
Additionally, isn't this de-optimizing the current implementations? By replacing dedicated classes with lambdas and thus adding a level of indirection and a larger per-instance footprint. Like, these are micro-optimizations, but isn't saving a few megabytes? :) |
Yes, that's mostly true on the JVM these days unless you are using the CMS GC without class unloading enabled.
Oh that's cool, I didn't think much about that. Do you know how fine-grained that is? Does it mean that the concerns for including
Well yes, that's megabytes per download per jar. I don't know what's the multiplier, but there is some multiplier. I would prefer to fix that in the compiler to be honest but we can't because of binary compatibility constraints. (Aside: at least that's what I've been told. Now that I think about it - those classes should be private anyway so why not? 🤔 Maybe I will give it a try but let's not discuss that here).
That's a good question - do we have any benchmarks on that? I can't say without trying. |
Some shower thoughts - the biggest cost of these instances are probably boxing of primitives (would be interesting to check in which cases it occurs) and tuple allocations. Lambda calls should not be that expensive because otherwise we would never get SAMs on the JVM. That leaves open the question about the cost of the additional indirection (which is necessary because of the compiler bug). |
Since we've reached the limit of our JVM knowledge - what about applying this only to the SAM type classes and leaving the multiple method type classes as they were? SAM conversion is supposed to work automatically by the compiler but because of the bug it's not the case. Obviously that means our jar size savings will be much less, but there will be no danger of performance regressions. |
👍 that sounds like a good way forward. |
@armanbilge done - now only SAM type classes are optimised in this way. I converted multi method type class instances back to anonymous classes. There were not that many actually - only |
def show[A](f: A => String): Show[A] = | ||
new Show[A] { | ||
def show(a: A): String = f(a) | ||
} | ||
def show[A](f: A => String): Show[A] = f(_) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a practical difference between these? e.g. no class is emitted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, indeed 👍 - because Show
has only one abstract method
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, thanks. Is this affected by the aforementioned Scala 2 bug?
Also, can Semigroup
etc. get the same treatment?
cats/kernel/src/main/scala/cats/kernel/Semigroup.scala
Lines 151 to 154 in 6ba7e4b
@inline def instance[A](cmb: (A, A) => A): Semigroup[A] = | |
new Semigroup[A] { | |
override def combine(x: A, y: A): A = cmb(x, y) | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think for Semigroup
it doesn't matter because it has other (non-abstract) methods. So then the bug applies and there would be an anonymous classes generated even if we use the SAM syntax. So the only benefit would be aesthetics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, so it's not just that Show
has one abstract method, it's that it has no other methods.
I agree it's only aesthetics on Scala 2, but does the bug apply to Scala 3 as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree it's only aesthetics on Scala 2, but does the bug apply to Scala 3 as well?
I don't remember - that's a good question.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason I ask is, I don't care so much about the handful of Semigroup.instance
etc. that can be re-written.
But I'm wondering if in Scala 3 the boilerplate instances themselves could be written directly like this instead of relying on instance
. So we get the win in terms of jar size without introducing any indirection at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even if that's possible it would be quite hard to split like this 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FTR Scala 3 doesn't have this bug - but again I'm sceptical about version-specific boilerplate generators 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there's interest it can be a followup PR. I think it shouldn't be too hard (famous last words): currently all the instances are in the same file I think, when they could easily be split among a few files. And some of those files can go into the scala-2 and scala-3 srcs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't review the boilerplate code in detail, but I did spent some time studying the decompiled bytecode of the generated classes. The extra indirection is unfortunate but quite possibly not a big deal (and potentially avoidable for Scala 3 in follow-up work). Given the various constraints I think this is good 👍
Sorry for coming to this late but there is a great page about the lambda translation process here and a good blog post with some further explanation of what happens at runtime here. Scalac uses the LambdaMetafactory provided in In the example that you provide: val cmb = (x, y) => (A1.cmb(x._1, y._1), A2.cmb(x._2, y._2))
val inv = x => (A1.inv(x._1), A2.inv(x._2)) These lambdas are capturing lambdas, but they are not instance-capturing lambdas (they do not use The static methods will be declared with the lambda parameters with any captured variables prepended to the parameter list. You can actually see this in the bytecode for the
The first two parameters are The VM spec says that:
So like @armanbilge suggests, these lambda expressions will capture
As of relatively recent versions of the JDK it seems that:
|
Thanks for detailed explanation @DavidGregory084 - I think that supports our current approach to apply this change to SAM type classes only |
Hey, is there any reason not to get this merged? 🙏 |
I'm happy to move forward with this as of #3871 (review). I feet like I remember someone (you?) saying we should still benchmark it or something before merging, but maybe I'm confused. |
I thought that referred to the instances which have more than one abstract method which are now removed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is tremendous.
We have previous approvals from Ross and Oscar as well, let's go ahead 🚀 |
In unrelated research, I found out that SAMs still generate classes on JS (unsurprisingly). So this PR actually didn't help things there, although I don't think it made things worse. Not sure the situation on Native, but if I had to guess it would be the same as JS. |
I assume both JS and Native have functions though - otherwise I can't explain the reduction of jar size I observed in both. Note that we use functions here, not SAM syntax directly to work around the Scala 2 bug. |
Ah, sorry, to clarify I'm not talking about the bytecode/SJSIR size. I'm talking about the size of the final generated JavaScript. Since JS is delivered into browsers this is a sensitive subject 😉 |
Right, sorry, I forgot this :) |
Make it a bit more readable by formatting constraints on a new line.
See #3870
This change results in around 40% jar size reduction on average of cats-kernel 😲
I've also give cats-core a similar treatment, but the savings will be much smaller there.