MethodHandles или LambdaMetafactory?

На моей работе у нас есть DSL для задания математических формул, который мы позже применяем к множеству точек (в миллионах).

На сегодняшний день мы создаем AST формулы и посещаем каждый узел, чтобы произвести то, что мы называем «оценщиком». Затем мы передаем этому вычислителю аргументы формулы, и для каждой точки он выполняет вычисления.

Например, у нас есть такая формула: x * (3 + y)

           ┌────┐
     ┌─────┤mult├─────┐
     │     └────┘     │
     │                │
  ┌──v──┐          ┌──v──┐
  │  x  │      ┌───┤ add ├──┐
  └─────┘      │   └─────┘  │
               │            │
            ┌──v──┐      ┌──v──┐
            │  3  │      │  y  │
            └─────┘      └─────┘

Наш оценщик будет генерировать объекты «Evaluate» для каждого шага.

Этот метод прост в программировании, но не очень эффективен.

Поэтому в последнее время я начал изучать дескрипторы методов, чтобы создать «составной» дескриптор метода для ускорения работы.

Что-то вроде этого: у меня есть класс "Арифметика" с:

public class Arithmetics {

  public static double add(double a, double b){
      return a+b;
  }

  public static double mult(double a, double b){
      return a*b;
  }

}

И при создании своего AST я использую MethodHandles.lookup (), чтобы напрямую обращаться к ним и составлять их. Что-то в этом роде, но в дереве:

Method add = ArithmeticOperator.class.getDeclaredMethod("add", double.class, double.class);
Method mult = ArithmeticOperator.class.getDeclaredMethod("mult", double.class, double.class);
MethodHandle mh_add = lookup.unreflect(add);
MethodHandle mh_mult = lookup.unreflect(mult);
MethodHandle mh_add_3 = MethodHandles.insertArguments(mh_add, 3, plus_arg);
MethodHandle formula = MethodHandles.collectArguments(mh_mult, 1, mh_add_3); // formula is f(x,y) = x * (3 + y)

К сожалению, я очень разочарован результатами. Например, фактическое построение дескриптора метода очень длинное (из-за вызовов MethodHandles :: insertArguments и других подобных функций композиции), а добавленное ускорение для оценки начинает иметь значение только после более чем 600 тысяч итераций.

При 10 млн итераций дескриптор метода начинает действительно сиять, но миллионы итераций не являются (пока?) Типичным вариантом использования. Мы больше в районе 10k-1M, где результат неоднозначен.

Кроме того, фактическое вычисление ускоряется, но не так сильно (~ 2-10 раз). Я ожидал, что эта штука будет работать немного быстрее ..

Так или иначе, я снова начал просматривать StackOverflow и увидел такие потоки LambdaMetafactory: https://stackoverflow.com/a/19563000/389405

И мне не терпится попробовать это. Но перед этим я хотел бы получить ваш ответ по некоторым вопросам:

  • Мне нужно уметь составлять все эти лямбды. MethodHandles предоставляет множество (медленных, одобрительных) способов сделать это, но я чувствую, что лямбды имеют более строгий «интерфейс», и я пока не могу понять, как это сделать. Ты знаешь как?

  • лямбда-выражения и дескрипторы методов довольно взаимосвязаны, и я не уверен, что получу значительное ускорение. Я вижу эти результаты для простых лямбд: direct: 0,02s, lambda: 0,02s, mh: 0,35s, reflection: 0,40 но как насчет составных лямбд?

Спасибо ребята!


person Gui13    schedule 08.06.2016    source источник
comment
Есть ли динамический компонент, предлагающий такие подходы? Обычное дерево (желательно неизменяемых) объектов не так уж и плохо.   -  person Holger    schedule 08.06.2016
comment
Наше дерево объектов фактически использует интерфейс, и мы обсудили стоимость всей динамической диспетчеризации и подумали, что, вероятно, нам будет лучше, если для этого будет создан специальный дескриптор метода. Мои тесты показывают, что это справедливо для больших деревьев или с большим количеством итераций для маленьких деревьев. Теперь мне интересно, могут ли лямбды улучшить это немного больше.   -  person Gui13    schedule 08.06.2016
comment
Выполняли ли вы фактические тесты относительно «стоимости всей динамической диспетчеризации»? Если у вас есть неизменяемое дерево, HotSpot обычно очень хорошо справляется с агрессивным встраиванием. И вам придется полагаться на эту возможность при использовании лямбда-выражений, так как они также разработаны для интерфейсов.   -  person Holger    schedule 08.06.2016
comment
Я думаю (но не тестировал) одним из преимуществ LambdaMetaFactory является то, что он создает новый класс, что означает, что JVM оптимизирует его гораздо более агрессивно, чем дескрипторы методов, хранящиеся в локальных полях или полях экземпляра. Единственный другой способ достичь того же эффекта - использовать Unsafe для создания анонимных классов. Так что, может быть, просто сохраните свое дерево MH и преобразуйте его в класс с LMF в качестве последнего шага? Если эти вещи создаются динамически, вы также можете настроить параметры JVM, чтобы JIT могли работать раньше. Обычно они настроены на достижение устойчивого состояния, а затем мало меняются.   -  person the8472    schedule 08.06.2016
comment
@ the8472: проблема в том, что текущая реализация LambdaMetaFactory не поддерживает составные дескрипторы методов. Таким образом, вы можете создавать только экземпляры, которые делегируются существующему методу, что подразумевает, что вам нужно самостоятельно сгенерировать код для объединения узлов. В основном об этом и заключается мой ответ: как только вы узнаете, что вам нужны эти методы, вы можете создавать узлы без отражающих операций, используя функции исходного уровня Java 8. Затем LMF все еще можно использовать для интеграции определенных специализированных конечных узлов.   -  person Holger    schedule 09.06.2016


Ответы (1)


Я думаю, что для большинства практических случаев неизменное дерево оценки, состоящее из узлов, выполняющих определенный интерфейс или наследующих от общего базового класса оценщика, непобедимо. HotSpot может выполнять (агрессивное) встраивание, по крайней мере, для поддеревьев, но может свободно решать, сколько узлов он будет встраивать.

Напротив, создание явного кода для всего дерева сопряжено с риском превышения пороговых значений JVM, тогда у вас есть код, который наверняка не имеет накладных расходов на отправку, но может работать интерпретируемым все время.

Дерево адаптированных MethodHandle начинается так же, как и любое другое дерево, но с более высокими накладными расходами. Спорный вопрос, способна ли его собственная оптимизация превзойти собственную стратегию встраивания HotSpots. И, как вы заметили, требуется большое количество вызовов, прежде чем сработает самонастройка. Похоже, что пороговые значения накапливаются неудачным образом для составных дескрипторов методов.

Чтобы назвать один из ярких примеров шаблона дерева оценки, когда вы используете _ 2_, чтобы подготовить операцию сопоставления регулярных выражений, ни байт-код, ни машинный код не будут сгенерированы, несмотря на то, что название метода может ввести в заблуждение мышление в этом направлении. Внутреннее представление - это просто неизменное дерево узлов, представляющее комбинации различных видов операций. Оптимизатор JVM должен сгенерировать для него сплющенный код, если это будет сочтено полезным.

Лямбда-выражения не меняют правила игры. Они позволяют создавать (небольшие) классы, выполняющие интерфейс и вызывающие целевой метод. Вы можете использовать их для построения неизменяемого оценочного дерева, и, хотя маловероятно, что оно будет иметь производительность, отличную от явно запрограммированных классов оценочных узлов, он позволяет использовать гораздо более простой код:

public class Arithmetics {
    public static void main(String[] args) {
        // x * (3 + y)
        DoubleBinaryOperator func=op(MUL, X, op(ADD, constant(3), Y));
        System.out.println(func.applyAsDouble(5, 4));
        PREDEFINED_UNARY_FUNCTIONS.forEach((name, f) ->
            System.out.println(name+"(0.42) = "+f.applyAsDouble(0.42)));
        PREDEFINED_BINARY_FUNCTIONS.forEach((name, f) ->
            System.out.println(name+"(0.42,0.815) = "+f.applyAsDouble(0.42,0.815)));
        // sin(x)+cos(y)
        func=op(ADD,
            op(PREDEFINED_UNARY_FUNCTIONS.get("sin"), X),
            op(PREDEFINED_UNARY_FUNCTIONS.get("cos"), Y));
        System.out.println("sin(0.6)+cos(y) = "+func.applyAsDouble(0.6, 0.5));
    }
    public static DoubleBinaryOperator ADD = Double::sum;
    public static DoubleBinaryOperator SUB = (a,b) -> a-b;
    public static DoubleBinaryOperator MUL = (a,b) -> a*b;
    public static DoubleBinaryOperator DIV = (a,b) -> a/b;
    public static DoubleBinaryOperator REM = (a,b) -> a%b;

    public static <T> DoubleBinaryOperator op(
        DoubleUnaryOperator op, DoubleBinaryOperator arg1) {
        return (x,y) -> op.applyAsDouble(arg1.applyAsDouble(x,y));
    }
    public static DoubleBinaryOperator op(
        DoubleBinaryOperator op, DoubleBinaryOperator arg1, DoubleBinaryOperator arg2) {
        return (x,y)->op.applyAsDouble(arg1.applyAsDouble(x,y),arg2.applyAsDouble(x,y));
    }
    public static DoubleBinaryOperator X = (x,y) -> x, Y = (x,y) -> y;
    public static DoubleBinaryOperator constant(double value) {
        return (x,y) -> value;
    }

    public static final Map<String,DoubleUnaryOperator> PREDEFINED_UNARY_FUNCTIONS
        = getPredefinedFunctions(DoubleUnaryOperator.class,
            MethodType.methodType(double.class, double.class));
    public static final Map<String,DoubleBinaryOperator> PREDEFINED_BINARY_FUNCTIONS
        = getPredefinedFunctions(DoubleBinaryOperator.class,
            MethodType.methodType(double.class, double.class, double.class));

    private static <T> Map<String,T> getPredefinedFunctions(Class<T> t, MethodType mt) {
        Map<String,T> result=new HashMap<>();
        MethodHandles.Lookup l=MethodHandles.lookup();
        for(Method m:Math.class.getMethods()) try {
            MethodHandle mh=l.unreflect(m);
            if(!mh.type().equals(mt)) continue;
            result.put(m.getName(), t.cast(LambdaMetafactory.metafactory(
            MethodHandles.lookup(), "applyAsDouble", MethodType.methodType(t),
            mt, mh, mt) .getTarget().invoke()));
        }
        catch(RuntimeException|Error ex) { throw ex; }
        catch(Throwable ex) { throw new AssertionError(ex); }
        return Collections.unmodifiableMap(result);
    }
}

Это все, что вам нужно для составления оценщиков для выражений, состоящих из основных арифметических операторов и функций, найденных в java.lang.Math, последние собираются динамически, чтобы решить этот аспект вашего вопроса.

Обратите внимание, что технически

public static DoubleBinaryOperator MUL = (a,b) -> a*b;

это просто сокращение для

public static DoubleBinaryOperator MUL = Arithmetics::mul;
public static double mul(double a, double b){
    return a*b;
}

Я добавил main метод, содержащий несколько примеров. Имейте в виду, что эти функции ведут себя как скомпилированный код, прямо при первом вызове, поскольку на самом деле они состоят только из скомпилированного кода, но состоят из нескольких функций.

person Holger    schedule 08.06.2016
comment
Это хороший ответ, спасибо Хольгер. У меня все еще есть серые области (как иметь переменное количество параметров), но это хорошее начало. - person Gui13; 10.06.2016