On the performance of copying case classes
Asked Answered
E

3

6

I have two case classes: addSmall and addBig. addSmall contains only one field. addBig contains several fields.

case class AddSmall(set: Set[Int] = Set.empty[Int]) {
  def add(e: Int) = copy(set + e)
}

case class AddBig(set: Set[Int] = Set.empty[Int]) extends Foo {
  def add(e: Int) = copy(set + e)
}

trait Foo {
  val a = "a"; val b = "b"; val c = "c"; val d = "d"; val e = "e"
  val f = "f"; val g = "g"; val h = "h"; val i = "i"; val j = "j"
  val k = "k"; val l = "l"; val m = "m"; val n = "n"; val o = "o"
  val p = "p"; val q = "q"; val r = "r"; val s = "s"; val t = "t"
}

A quick benchmark using JMH shows that copying addBig objects is way more exprensive even if i change only one field..

import java.util.concurrent.TimeUnit
import org.openjdk.jmh.annotations._

@State(Scope.Benchmark)
class AddState {
  var elem: Int = _
  var addSmall: AddSmall = _
  var addBig: AddBig = _

  @Setup(Level.Trial)
  def setup(): Unit = {
    addSmall = AddSmall()
    addBig = AddBig()
    elem = 1
  }
}

@OutputTimeUnit(TimeUnit.MILLISECONDS)
@BenchmarkMode(Array(Mode.Throughput))
class SetBenchmark {
  @Benchmark
  def addSmall(state: AddState): AddSmall = {
    state.addSmall.add(state.elem)
  }

  @Benchmark
  def addBig(state: AddState): AddBig = {
    state.addBig.add(state.elem)
  }
}

And the results show that copying addBig is more than 10 times slower than copying addSmall!

> jmh:run -i 5 -wi 5 -f1 -t1
[info] Benchmark                                   Mode  Cnt       Score       Error   Units
[info] LocalBenchmarks.Set.SetBenchmark.addBig    thrpt    5   10732.569 ±   349.577  ops/ms
[info] LocalBenchmarks.Set.SetBenchmark.addSmall  thrpt    5  126711.722 ± 10538.611  ops/ms

How come copying the object is much slower for addBig? As far as i understand structural sharing, since all fields are immutable copying the object should be very efficient as it only needs to store the changes ("delta") which in this case is only the set s, and should thus give the same performance as addSmall.


EDIT: The same performance issue arises when the state is part of the case class.

case class AddBig(set: Set[Int] = Set.empty[Int], a: String = "a", b: String = "b", ...) {
  def add(e: Int) = copy(set + e)
}
Edvard answered 21/2, 2020 at 14:47 Comment(3)
The objects to which they vals point are shared, but the pointers have to be copied. Usually, this shouldn't be a problem, but if its, then consider encapsulating everything that won't change in one case class, so you end up just copying one reference.Meingoldas
@LuisMiguelMejíaSuárez Initially i didn't understand the 10 times speed decrease only for copying 19 pointers. But probably adding the element to the set is so fast that copying 19 pointers is considerably more work and dominates the execution time.Edvard
is the only thing I can think of. Having said that, do not trust me, please do proper benchmarking and try to check the generated bytecode for insight.Meingoldas
O
6

I guess, that this is because AddBig class extends Foo trait, which has all this String fields - a to t. It seems like, in result object they will be declared as regular fields, not the static fields if compare to Java, hence allocating memory for the object, might be the root cause of slower copy performance.

UPDATE: In order to verify this theory you can try to use JOL (Java Object Layout) tool - openjdk.java.net/projects/code-tools/jol

Here is the simple code example:

import org.openjdk.jol.info.{ClassLayout, GraphLayout}
println(ClassLayout.parseClass(classOf[AddSmall]).toPrintable())
println(ClassLayout.parseClass(classOf[AddBig]).toPrintable())

println(GraphLayout.parseInstance(AddSmall()).toPrintable)
println(GraphLayout.parseInstance(AddBig()).toPrintable)

Which in my case produced next output (short version for answer readability):

xample.AddSmall object internals:
 OFFSET  SIZE                             TYPE DESCRIPTION                               VALUE
      0    12                                  (object header)                           N/A
     12     4   scala.collection.immutable.Set AddSmall.set                              N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

example.AddBig object internals:
 OFFSET  SIZE                             TYPE DESCRIPTION                               VALUE
      0    12                                  (object header)                           N/A
     12     4   scala.collection.immutable.Set AddBig.set                                N/A
     16     4                 java.lang.String AddBig.a                                  N/A
     20     4                 java.lang.String AddBig.b                                  N/A
     24     4                 java.lang.String AddBig.c                                  N/A

Instance size: 96 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

example.AddSmall@ea1a8d5d object externals:
          ADDRESS       SIZE TYPE                                     PATH                           VALUE
        770940b28         16 example.AddSmall                                                        (object)
        770940b38     470456 (something else)                         (somewhere else)               (something else)
        7709b38f0         16 scala.collection.immutable.Set$EmptySet$ .set                           (object)


example.AddBig@480bdb19d object externals:
          ADDRESS       SIZE TYPE                                     PATH                           VALUE
        770143658         24 java.lang.String                         .h                             (object)
        770143670         24 [C                                       .h.value                       [h]
        770143688      15536 (something else)                         (somewhere else)               (something else)
        770147338         24 java.lang.String                         .m                             (object)
        770147350         24 [C                                       .m.value                       [m]
        770147368    1104264 (something else)                         (somewhere else)               (something else)
        770254cf0         24 java.lang.String                         .r                             (object)
        770254d08         24 [C                                       .r.value                       [r]
        770254d20    7140768 (something else)                         (somewhere else)               (something else)
        7709242c0         24 java.lang.String                         .a                             (object)

So as you can see fields from parent trait become class fields as well, so will be copied along with the object.

Hope this helps!

Orlandoorlanta answered 21/2, 2020 at 14:54 Comment(7)
The same performance issue arises when the fields are defined in the case class' constructor case class AddBig(set: Set[Int] = Set.empty[Int], a: String = "a", ...). Since all the fields are immutable, it could reuse them without having to allocate all that memory again for this object (i.e. structural sharing), or not?Edvard
@Kevin guess not, because they located in a trait not the object let's say. I suggest you try reduce amount of fields to 1 for instance and make one more experiment. Also, you can try jol tool to check the theory : openjdk.java.net/projects/code-tools/jolOrlandoorlanta
@Kevin I've posted an update regarding object layout.Orlandoorlanta
I will have to look into this. Are the values contained by the fields copied or only the pointers? Thanks for you help :)Edvard
@Kevin the pointers - this is not the deep copy. But anyway, the pointer allocates 1 machine word (1 byte on 32x system and 2 bytes on 64x system) of memory. So AddBig needs 16 fields * 2 = 32 bytes of additional memory.Orlandoorlanta
@Jasper-M thank you for correction, you are completely right. Sorry, about confusion. Then we are talking about of 64 bytes of additional memory in AddBigOrlandoorlanta
Thanks for your help, i will further investigate this on monday. I will accept your answer when i figured this out.Edvard
R
1

Have you checked this question? scala case class copy implementation You can check compiler generated things to elaborate this. There's a probability that these vals became regular fields of case class and being copied each time class copied.

Romaic answered 21/2, 2020 at 14:57 Comment(0)
C
1

Your Foo trait adds 20 members to every subclass even though they are constants. This is going to use more memory and make copying the class slower.

Consider

1) Making them def rather than val so they are no longer data members

OR

2) Moving them into the companion class for the trait and accessing as Foo.a etc.

Cydnus answered 21/2, 2020 at 16:27 Comment(4)
Well, this is just an artificial example but in my application they are not constants.Edvard
I would not recommend putting non-private vars in a trait. If they are expressions it might still be worth making them def unless they are very complex to compute.Cydnus
They are private vars and are thus modified by the trait’s methods. One of these vars is for instance a graph that is updated each time a certain method of the trait is called.Edvard
Foo sounds like a much more complicated class than AddSmall, in which case it is not surprising that it takes longer to copy. Making lots of copies of classes with vars in them is a long way from the functional design that Scala does best, so maybe look at making the whole design more functional before worrying too much about copy performance.Cydnus

© 2022 - 2024 — McMap. All rights reserved.