Optimize Switch for Constants

“They must often change, who would be constant in happiness or wisdom.”

Confuzius

The FreshMarker Switch Directive is an unusual representative of its kind. Since it has been implemented according to the possibilities of its syntax description in the CongoCC example source code, it is capable of quite unusual working methods. Some special cases could be processed faster with an optimized version. Therefore, the Switch Directive is optimized for constants in this article.

The reason for the possibilities of the Switch Directive is hidden in the CongoCC grammar for FreshMarker.

Switch #SwitchInstruction : 
    (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>)
    <SWITCH> =>||
    <BLANK>
    Expression
    <CLOSE_TAG>
    [<WHITESPACE>]
    (Case)*
    [
     SCAN 2 Default
    ]
    DirectiveEnd("switch")
;

Case #CaseInstruction : 
    (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>)
    (<CASE>|<ON>)
    =>||
    <BLANK>
    Expression (<COMMA> Expression)* <CLOSE_TAG> Block
;

As you can see from this excerpt, a SwitchInstruction consists of a switch tag with an Expression and any number of CaseInstructions. The CaseInstruction in turn consists of a case or on tag with any number of Expressions and a subsequent Block. A grammar does not normally say everything about a programming language. Many parts of the semantics of a language result from how parts of the grammar are used.

A simple example is the CaseInstruction, apparently a case tag, just like the on tag, can contain any number of Expressions. Although the syntax offers the possibility, the FreshMarker interpreter does not allow this. An error is output for case tags with more than one expression. Only the on tag may contain several Expressions.

The Expressions allowed in the tags can be arbitrarily complicated Expressions, but the values should be evaluated to the same primitive FreshMarker types. Firstly, because the necessary comparisons in FreshMarker are only defined on primitive types and secondly, of course, so that the expression in the switch tag can match at least one expression in the case tag.

The story with the same type is not so simple in FreshMarker, because the type of the result can only be determined when the Expression is evaluated. This is because FreshMarker still has no type information stored in the template.

The following example shows the possibilities and problems of the FreshMarker Switch Directive.

<#switch 42>
<#case answer>
the answer to the ultimate question of life, 
the universe, and everything.
<#case 6 * 7>
the ultimate question.
<#case question>
the ultimate question.
<#case 'fortytwo'>
the answer to the ultimate question of life, 
the universe, and everything.
</#switch>

Unlike usual, in this example the switch tag does not contain a variable but a constant. This is possible, but also means that there should be a case tag whose Expression leads to this constant. In this example, three of the case tags can fulfill this condition. If the variables question or answer contain the value 42, then they can fulfill the condition, otherwise the second case tag fulfills the condition, because the Expression 6*7 always results in 42. The last case tag is never used because the Expression always results in 'fortytwo' and, of course, the second case tag always wins.

Why does the second case tag win? The ConditionalFragment instances representing the CaseInstructions are processed in the order in which they occur because each Expression can potentially be successful.

This is quite a weird example, normally the Switch Directive is used differently.

<#switch value>
<#case 1>Eins/One
<#case 2>Zwei/Two
<#case 3>Drei/Three
</#switch>

A variable value is specified in the switch tag and all case tags contain an integer constant. Depending on the value of the variable, one of the ConditionalFragment instances can then be evaluated. As all three constants differ from each other, it does not matter which of them is checked first.

This type of switch directive allows us to switch from the previous implementation as List<ConditionalFragment> to another familiar data structure. In this case Map<TemplatePrimitive<?>, Fragment> is the better choice. For our example, the primitive keys would be TemplateNumber instances of 1, 2 and 3 and the values, ConstantFragment instances for the values Eins/One, Zwei/Two and Drei/Three.

public class MapSwitchFragment extends AbstractConditionalFragment implements SwitchFragment {

    private final TemplateObject switchExpression;
    private final Map<TemplatePrimitive<?>, Fragment> fragmentMap;

    // ...

    public void process(ProcessContext context) {
        TemplatePrimitive<?> switchValue = evaluatePrimitive(this.switchExpression, context, node);
        fragmentMap.getOrDefault(switchValue, endFragment).process(context);
    }
}

The implementation of the new MapSwitchFragment ist trivial, it contains the switchExpression containing the Expression from the switch tag and the fragmentMap described above. The process method is also implemented simply, the switchValue is evaluated and then the appropriate entry from the fragmentMap is used. If no suitable entry is found, the default case is available with endFragment.

Die Einfachheit der MapSwitchFragment erkauft man sich durch eine größere Komplexität bei der Auswertung der Switch Direktive.

@Override
public SwitchFragment visit(SwitchInstruction ftl, SwitchFragment input) {
  Node expression = ftl.get(3);
  TemplateObject switchExpression = expression.accept(interpolationBuilder, null);
  Map<NodeType, List<CaseInstruction>> parts = ftl.childrenOfType(CaseInstruction.class).stream().collect(groupingBy(p -> p.get(1).getType()));
  List<CaseInstruction> caseParts = parts.getOrDefault(TokenType.CASE, List.of());
  List<CaseInstruction> switchOnParts = parts.getOrDefault(TokenType.ON, List.of());
  if (!caseParts.isEmpty() && !switchOnParts.isEmpty()) {
    throw new ParsingException("switch directive contains on and case", ftl);
  }
  if (!caseParts.isEmpty()) {
    handle(caseParts, collector, ftl, featureSet.isEnabled(ALLOW_ONLY_CONSTANT_CASES) && featureSet.isEnabled(ALLOW_ONLY_EQUAL_TYPE_CASES));
  }
  if (!switchOnParts.isEmpty()) {
    handle(switchOnParts, collector, ftl, featureSet.isEnabled(ALLOW_ONLY_CONSTANT_ONS) && featureSet.isEnabled(ALLOW_ONLY_EQUAL_TYPE_ONS));
  }
  DefaultInstruction defaultPart = ftl.firstChildOfType(DefaultInstruction.class);
  if (defaultPart != null) {
    defaultPart.accept(this, null);
  }
  if (featureSet.isEnabled(SwitchDirectiveFeature.OPTIMIZE_CONSTANT_SWITCH) && collector.isAllPrimitive) {
    Map<TemplatePrimitive<?>, Fragment> switchMap = collector.fragments.stream()
        .collect(toMap(c -> (TemplatePrimitive<?>) c.conditional(), ConditionalFragment::content, (a, b) -> a));
    if (switchMap.size() < collector.fragments.size() && featureSet.isEnabled(SwitchDirectiveFeature.ERROR_ON_DUPLICATE_CASE_EXPRESSION)) {
      throw new ParsingException("switch with duplicate conditionals", ftl);
    }
    return new MapSwitchFragment(switchExpression, expression, switchMap, collector.fragment);
  }
  return new ListSwitchFragment(switchExpression, expression, collector.fragments, collector.fragment);
}

The previous SwitchFragment implementation has been moved to the ListSwitchFragment class. ListSwitchFragment and MapSwitchFragment both implement the new SwitchFragment interface. Previously, a SwitchFragment was created directly; now the ConditionalFragments are collected first and a decision is made at the end of the method as to which implementation should be used.

When collecting, the system checks whether the expression of the ConditionalFragment instances are all constants. If this is the case, a MapSwitchFragment can be created, otherwise a ListSwitchFragment.

Two new FreshMarker features round off the use of the MapSwitchFragment. The new implementation can be activated with OPTIMIZE_CONSTANT_SWITCH. If it proves successful, this feature will be removed in one of the following versions. With ERROR_ON_DUPLICATE_CASE_EXPRESSION an error message is thrown if a constant is used more than once. This is checked via the cardinality of the created Map and the collecting List. If the Map contains fewer entries, then a second occurrence of a constant was rejected by the expression (a, b) -> a.

The new MapSwitchFragment should not only be able to be evaluated, but also reduced by the Partial Template Reduction mechanism.

@Override
public Fragment reduce(ReduceContext context) {
  try {
    TemplatePrimitive<?> switchValue = evaluatePrimitive(switchExpression, context, node);
    return fragmentMap.getOrDefault(switchValue, endFragment).reduce(context);
  } catch (WrongTypeException e) {
    throw new ReduceException(e.getMessage(), node, e);
  } catch (ProcessException ignored) {
    return new MapSwitchFragment(switchExpression, node, reduceMap(context), endFragment.reduce(context));
  }
}

The Fragment can be replaced if the switchVariable can be evaluated and no exception is thrown when the Fragment from the Map is reduced. In this case, the SwitchFragment is replaced by the reduced Fragment from a case tag or the default tag. If an error occurs, all Fragment instances from the Map are reduced and a new MapSwitchFragment is created with the newly created Map.

The goal of optimization was also achieved with the new implementation. The more case tags there are in the Switch Directive, the greater the speed advantage of the new implementation. The position and visit frequency of the case tags must also be considered. Simple measurements have so far shown a doubling of the speed.

This feature will be part of the next version 1.11.0 of FreshMarker. Stay tuned!

Leave a Comment