Scalaの構造的部分型の性能に注意


今回はScalaの構造的部分型(structual subtyping)の話です。

構造的部分型を使うと、いちいちインタフェースであるtraitやclassを用意しなくて良いため、便利です。ですが、構造的部分型はコンパイルするとリフレクションに置き換えられるため、性能面では若干心配です。

今回は性能を検証してみようと思います。結論から言うと構造的部分型を利用するとメソッド呼び出しにかかるコストが41倍も増えてしまいます。(つまり沢山呼ばれる部分に使うと辛い)

構造的部分型のサンプル

まずはおさらいと例です。構造的部分型はこんな感じですね。

import scala.language.reflectiveCalls

object Main {
  def main(args: Array[String]): Unit = {
    val obj = new {
      def findAll():List[Int] = {
        1 :: 2 :: Nil
      }
    }

    printAll(obj)
  }

  def printAll(obj: {def findAll():List[_]}): Unit = {
    obj.findAll().foreach(println)
  }
}

このように書くことで、通常インタフェース用のTraitを用意して、継承関係を考慮しつつ関数を定義して・・・ということが不要になります。printAllメソッドにはfindAll():List[_]という関数を持つオブジェクトであればなんでも与えられるようになります。

リフレクションの確認

性能の検証に入る前に、本当にリフレクションが使われているかを確かめてみます。

先ほどのソースをMain.scalaとして保存して、scalacでコンパイルしてみます。

scalac Main.scala

いくつかファイルができました。.classファイルは4つできていますね。

[tsukaby@PC workspace]% ll
total 40
-rw-r--r--  1 tsukaby  staff   877  3 19 10:42 Main$$anon$1.class
-rw-r--r--  1 tsukaby  staff  1017  3 19 10:42 Main$$anonfun$printAll$1.class
-rw-r--r--  1 tsukaby  staff  2235  3 19 10:42 Main$.class
-rw-r--r--  1 tsukaby  staff   849  3 19 10:42 Main.class
-rw-r--r--  1 tsukaby  staff   304  3 19 10:42 Main.scala

これをjava decompilerのGUI版で読み込んでみます。Main$.classはこのようになっています。

import java.lang.ref.SoftReference;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import scala.Predef.;
import scala.Serializable;
import scala.collection.immutable.List;
import scala.collection.immutable.Nil.;
import scala.runtime.AbstractFunction1;
import scala.runtime.BoxedUnit;
import scala.runtime.BoxesRunTime;
import scala.runtime.EmptyMethodCache;
import scala.runtime.MethodCache;
import scala.runtime.ScalaRunTime.;

public final class Main$
{
  public static final  MODULE$;
  private static Class[] reflParams$Cache1 = new Class[0]; private static volatile SoftReference reflPoly$Cache1 = new SoftReference(new EmptyMethodCache());

  static { new (); } 
  public static Method reflMethod$Method1(Class x$1) { MethodCache methodCache1 = (MethodCache)reflPoly$Cache1.get(); if (methodCache1 == null) { methodCache1 = new EmptyMethodCache(); reflPoly$Cache1 = new SoftReference(methodCache1); } Method method1 = methodCache1.find(x$1); if (method1 != null) return method1; method1 = ScalaRunTime..MODULE$.ensureAccessible(x$1.getMethod("findAll", reflParams$Cache1)); reflPoly$Cache1 = new SoftReference(methodCache1.add(x$1, method1)); return method1;
  }

  public void main(String[] args)
  {
    Object obj = new Object() {
      public List<Object> findAll() {
        int i = 1; int j = 2; return Nil..MODULE$.$colon$colon(BoxesRunTime.boxToInteger(j)).$colon$colon(BoxesRunTime.boxToInteger(i));
      }
    };
    printAll(obj);
  }

  public void printAll(Object obj) {
    Object qual1 = obj;
    try { ((List)reflMethod$Method1(qual1.getClass()).invoke(qual1, new Object[0])).foreach(new AbstractFunction1() { public static final long serialVersionUID = 0L;

        public final void apply(Object x) { Predef..MODULE$.println(x); }

      });
      return; } catch (InvocationTargetException localInvocationTargetException) { throw localInvocationTargetException.getCause(); } 
  }
  private Main$() { MODULE$ = this; }

}

いろいろありますが、重要なのはここで

try { ((List)reflMethod$Method1(qual1.getClass()).invoke(qual1, new Object[0]))

methodオブジェクトを取ってinvokeを呼び出していますね。

確かにリフレクションになっています。

性能検証

いよいよ性能を見ていきたいと思います。

上記の例のままだと検証しづらいので検証用に以下のコードを用意しました。sbt-jmhというプラグインを使って性能測定をします。

package com.example

import org.openjdk.jmh.annotations.Benchmark

import scala.language.reflectiveCalls

trait CalcOperator {
  def add(a: Int, b: Int): Int = a + b
}

object Bar extends CalcOperator

class Foo {

  @Benchmark
  def callerWithoutReflection(): Unit = {
    notReflectionFunc(Bar, 1, 2)
  }

  @Benchmark
  def callerWithReflection(): Unit = {
    reflectionFunc(Bar, 1, 2)
  }

  def notReflectionFunc(calcOperator: CalcOperator, a: Int, b: Int): Int = {
    calcOperator.add(a, b)
  }
  def reflectionFunc(obj: {def add(a: Int, b: Int): Int}, a: Int, b: Int): Int = {
    obj.add(a, b)
  }

}

CalcOperatorというtraitを用意して、これを経由して処理を行うnotReflectionFuncと、CalcOperator関係なしに構造的部分型で処理を行うreflectionFuncを用意しました。

これによって片方はリフレクション無しで、片方はリフレクションでaddを呼び出すことになります。

sbt-jmhでベンチマークを行います。

sbt run -i 20 -wi 10 -f1 -t1

結果

いろいろ出力されますが、最後に以下のようにサマリーがでます。

[info] # Run complete. Total time: 00:01:01
[info] 
[info] Benchmark                     Mode  Cnt           Score          Error  Units
[info] Foo.callerWithReflection     thrpt   20    51023318.959 ±   940485.334  ops/s
[info] Foo.callerWithoutReflection  thrpt   20  2058953118.323 ± 69404149.696  ops/s

数値が高い方が速いため、リフレクション無しversionの方が圧倒的に早いことが分かりました。5:205なので、リフレクション無しversionの方が41倍早いです。

まとめ

構造的部分型はリフレクションとして扱われるため、メソッド呼び出しは相当**遅くなります。**上記の通り41倍という開きがあります。

構造的部分型の利用には注意しましょう。

 

おまけ

なるほど、そんな使い道もあるのか・・・