Case study – a more performant option

If you are not yet ready to lose information that is encoded by the Option data type, then you may wish to explore alternative implementations of Option that are more garbage-collection-friendly. In this section, we present an alternative approach that also provides type-safety while avoiding boxing and instantiation of the Some instances. We leverage tagged types and specialization, and disallow null as a valid value for Some to come up with the following implementation:

sealed trait Opt 
 
object OptOps { 
 
  def some[@specialized A](x: A): A @@ Opt = Tag(x) 
  def nullCheckingSome[@specialized A](x: A): A @@ Opt = 
    if (x == null) sys.error("Null values disallowed") else Tag(x) 
  def none[A]: A @@ Opt = Tag(null.asInstanceOf[A]) 
 
  def isSome[A](o: A @@ Opt): Boolean = o != null 
  def isEmpty[A](o: A @@ Opt): Boolean = !isSome(o) 
 
  def unsafeGet[A](o: A @@ Opt): A = 
    if (isSome(o)) o.asInstanceOf[A] else sys.error("Cannot get None") 
 
  def fold[A, B](o: A @@ Opt)(ifEmpty: => B)(f: A => B): B = 
    if (o == null) ifEmpty else f(o.asInstanceOf[A]) 
} 

This implementation defines factory methods to construct optional types (that is, somenullCheckingSome, and none). In contrast to Scala's Option, this implementation uses tagged types to add type information to a value rather than creating a new value to encode optionality. The implementation of none takes advantage of null being a value in Scala rather than a language in keyword as is the case in Java. Remember, unless performance requirements required such extreme measures, we would not default to these more esoteric approaches. The tagged type returned by each factory method preserves type-safety, and it requires an explicit unwrapping to access the underlying type.

Note

If you would like to learn more about Scala's representation of the null value, we encourage you to check out these two StackOverflow posts at http://stackoverflow.com/questions/8285916/why-doesnt-null-asinstanceofint-throw-a-nullpointerexception and http://stackoverflow.com/questions/10749010/if-an-int-cant-be-null-what-does-null-asinstanceofint-mean. In both posts, multiple responders provide excellent responses that will help you deepen your understanding.

The remaining methods in OptOps define methods that you would find in the implementation of Scala's Option. Rather than instance methods, we see that the methods are static because there are no new instances that are allocated by the factory methods. It is possible to define an implicit class that would provide a syntax emulating instance method invocation, but we avoid doing this because we are operating under the assumption of extreme performance sensitivity. Semantically, the operations that are defined in OptOps are equivalent to the Scala Option analogs. Instead of matching against a value representing no value (that is, None), we again take advantage of the ability to reference null as a value.

With this implementation, the runtime overhead consists of instance checking and invocations of scalaz.Tag. We lose the ability to pattern match, and instead we must either fold or, in extreme cases, use isSome and unsafeGet. To get a better understanding of runtime differences, we microbenchmarked Option creation using Scala's Option and the preceding tagged type implementation. The microbenchmark gives you a taste for the change in syntax. We encourage you to run javap to disassemble the bytecode in order to prove to yourself that this implementation avoids boxing and object creation:

 class OptionCreationBenchmarks { 
 
  @Benchmark 
  def scalaSome(): Option[ShareCount] = Some(ShareCount(1)) 
 
  @Benchmark 
  def scalaNone(): Option[ShareCount] = None 
 
  @Benchmark 
  def optSome(): ShareCount @@ Opt = OptOps.some(ShareCount(1)) 
 
  @Benchmark 
  def optSomeWithNullChecking(): ShareCount @@ Opt = 
    OptOps.nullCheckingSome(ShareCount(1)) 
 
  @Benchmark 
  def optNone(): ShareCount @@ Opt = OptOps.none 
 
  @Benchmark 
  def optNoneReuse(): ShareCount @@ Opt = noShares 
} 
 
object OptionCreationBenchmarks { 
  case class ShareCount(value: Long) extends AnyVal 
  val noShares: ShareCount @@ Opt = OptOps.none 
} 

We run the test with the following familiar parameters:

sbt 'project chapter3' 'jmh:run OptionCreationBenchmarks  -foe true'

The results are summarized in the following table:

Benchmark

Throughput (ops per second)

Error as percentage of throughput

optNone

351,536,523.84

±0.75

optNoneReuse

344,201,145.90

±0.23

optSome

232,684,849.83

±0.37

optSomeWithNullChecking

233,432,224.39

±0.28

scalaNone

345,826,731.05

±0.35

scalaSome

133,583,718.28

±0.24

Perhaps the most impressive result here is that throughput increases approximately 57% when using the tagged type implementation of Some over the Scala-provided implementation. This is likely due to reduced memory allocation pressure. We see that None creation throughput is qualitatively similar. We also observe that there appears to be zero cost to add a null check in the construction of a tagged Some option. If you trust your team to avoid passing around null values, then the check is superfluous. We also created a set of benchmarks to evaluate fold performance to get a sense of the relative cost of using this alternative Option implementation. Here is the source code for a simple fold benchmark:

class OptionFoldingBenchmarks { 
 
  @Benchmark 
  def scalaOption(): ShareCount = 
    scalaSome.fold(ShareCount(0))(c => ShareCount(c.value * 2)) 
 
 
  @Benchmark 
  def optOption(): ShareCount = 
    OptOps.fold(optSome)(ShareCount(0))(c => ShareCount(c.value * 2)) 
 
} 
 
object OptionFoldingBenchmarks { 
 
  case class ShareCount(value: Long) extends AnyVal 
 
  val scalaSome: Option[ShareCount] = Some(ShareCount(7)) 
  val optSome: ShareCount @@ Opt = OptOps.some(ShareCount(7)) 
} 

This benchmark was run using the same set of parameters as before:

jmh:run OptionFoldingBenchmarks  -foe true

The results of this test are summarized in the following table:

Benchmark

Throughput (ops per second)

Error as percentage of throughput

optOption

346,208,759.51

±1.07

scalaOption

306,325,098.74

±0.41

In this benchmark we are hoping to prove that there is no significant throughput degradation when using the alternative tagged type-inspired implementation over the Scala Option. A significant degradation in performance would jeopardize the performance wins that we found in the creation benchmark. Fortunately, this benchmark suggests fold throughput actually increases approximately 13% over the Scala Option fold implementation.

Note

It is a relief to see benchmarking yield results that confirm your hypothesis. However, it is equally important to understand why favorable results were produced, and to be able to explain this. Without an understanding of how these results happened, you are unlikely to be able to reproduce the results. How would you explain fold throughput improvement of the tagged type-inspired implementation over the Scala Option implementation? Consider the implementation and memory allocation differences that we covered.

The benchmarks suggest that the tagged type-inspired Option implementation yields qualitative performance improvements over the Scala Option implementation. If you are faced with a performance issue and profiling reveals the Scala Option to be the bottleneck, it may make sense to explore this alternative implementation. While the performance improves, realize that a tradeoff exists. When using the alternative implementation, you lose the ability to pattern match. This seems like a small price to pay because you are able to instead use the fold operation. The higher price to pay is integration with the standard library and third-party libraries. If your critical path code interacts heavily with the Scala standard library or a third-party library, you will be forced to rewrite significant chunks of code to use the alternative Option implementation. In this scenario, if you are under time pressure, it may make sense to reconsider whether or not modeling parts of the domain with null is sensible. If your critical path code avoids significant interaction with the Scala standard library or third-party libraries, then using the alternative Option implementation might be an easier decision.

Our case study is inspired by a novel approach Alexandre Bertails explores in his blog post at https://bertails.org/2015/02/15/abstract-algebraic-data-type/. He solves the same performance issues that we addressed by defining an approach that he refers to as abstract algebraic data types. Both approaches rely on using type constraints to model Option without instance allocation. By abstracting over the Option algebraic data type and its operations, he is able to devise an implementation free of allocations and boxing. We encourage you to explore this approach because it is another great example of how to achieve safety while still providing excellent performance.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset