Feel.Backend.Beam.Scalar (feel v0.1.0)

Copy Markdown View Source

The guarded fast path of the BEAM backend, for the expressions that only do scalar work on declared names (D-164).

The general emitter calls shared runtime operations. This specialization removes dispatch and repeated kind checks where declared scalar types establish the required assumptions. Numeric and boolean operations no longer carry a step counter. String construction still checks individual value-size limits.

The subset

An expression is in it when its nodes are all of these, and there are at most 32 of them:

  • constants: a number, a string, true or false;
  • a name that is read from the bindings, whose kind is number, string or boolean (the type that the scope declares for it, or, if the expression was not given declarations, the one the analyzer found);
  • + - * / of two numbers, + of two strings, and -x of a number;
  • = and != of two numbers, two strings or two booleans, and <, <=, > and >= of two numbers;
  • and, or and not(x) of booleans, and between of three numbers;
  • in, with tests that are such expressions of booleans, which is what a range or a comparison written as a unary test becomes (age in [18..65));
  • if then else of a boolean, where it is the value of the expression or the value of a branch of one that is, so that what follows a branch is the end of the run (if a then 1 else if b then 2 else 3); an if that is an operand is left to the emitter.

Everything else (contexts, lists, loops, functions, calls of built-in functions other than not, paths, dates and durations, the unary tests themselves, allowed values, a declared result) is left to the emitter, and so is an expression that has no name to read, which the optimizer has folded.

What it does

It reads the names it needs from the bindings in one pattern, with a guard for each that says it is a number (a Decimal that is finite, of at most 34 digits and an exponent from -6176 to 6111: a guard cannot count the digits of a coefficient, so a number with a larger exponent, 1E+6144, takes the clause that does not match and the fallback; D-214), a string or a boolean, and a string that is declared is asked whether it is valid UTF-8 (Feel.Runtime.Kind.string?/1), as the check of the declared types asks. A value that is not one, a name that has no binding, or a binding that is spelled differently, is a clause that does not match, and so is a null where the name is read by an operation that reports it (below). The declared names that the expression does not read are checked as well, since the declared types are checked for all of them. With the values in hand, the operations are done in a straight line without an environment and without an outcome: a case on 'Elixir.Feel.Runtime.Number':multiply(A, B) for an operation that gives a number, a comparison of the order that Feel.Runtime.Number.compare/2 gives, the operators of the language for strings and booleans. The largest constructed string is checked against max_string_length at the end of each path, including intermediate strings when the final result is boolean. Successful paths return the original environment.

run(Bindings, E0) ->
    case Bindings of
        #{<<"price">> := #{'__struct__' := 'Elixir.Decimal', coef := C1, exp := X1} = P1,
          <<"quantity">> := #{'__struct__' := 'Elixir.Decimal', coef := C2, exp := X2} = P2,
          <<"tax">> := #{'__struct__' := 'Elixir.Decimal', coef := C3, exp := X3} = P3}
            when is_integer(C1), C1 < 10^34, is_integer(X1), X1 >= -6176, X1 =< 6111,
                 ...the same for C2, X2 and C3, X3 ->
            case 'Elixir.Feel.Runtime.Number':multiply(P1, P2) of
                {ok, V1} ->
                    case 'Elixir.Feel.Runtime.Number':add(Decimal1, P3) of
                        {ok, V2} ->
                            case 'Elixir.Feel.Runtime.Number':multiply(V1, V2) of
                                {ok, V3} -> {V3, E0};
                                _ -> fallback(Bindings, E0)
                            end;
                        _ -> fallback(Bindings, E0)
                    end;
                _ -> fallback(Bindings, E0)
            end;
        _ -> fallback(Bindings, E0)
    end.

fallback(Bindings, E0) ->
    'Elixir.Feel.Runtime.Interpret':run(IR, Bindings, E0).

A null

Most operations report a null operand (Feel.Runtime.NullOperand, D-103), and the path leaves those to the interpreter. Some answer for it and report nothing: < and > are null, = and != are a boolean (null = null is true, a number is not equal to null), and, or and not are the three-valued logic (false and null is false), and the condition of an if that is not true takes the else. The arithmetic, <=, >=, between, negation, the join of strings and the subject of an in report it. Every value that the path makes knows the names that make it null (none: it is a value of its kind), and an operation that answers for one has code for it, which a case of nil is: the same value and diagnostics as the interpreter gives.

A name may be null in the guard that reads it only if every operation it is an operand of answers for it. The first that does not says which names it was given, they are made values (strict), and the code is made again: there is one more strict name at each attempt, and an attempt is a walk of at most 32 nodes. A name declared ! is strict from the start, since a null of it ends the run. A name that is not strict is read by a guard that says null or a value of its kind, which costs a little more than the pattern of a value, and a declared string that is not strict is asked to be valid only if it is not null.

What it does not do

It does not report anything, and it does not end a run. Anything that the interpreter would report or end the run for, the path takes as a sign that it is not the one to answer: a null that is reported, a name that has no value, a value that has to be converted to the declared type (a list of one number, a date for a date and time) or cannot be, an operation that gives an error (an overflow, a division by zero, a number of more than 34 digits), and a constructed string longer than its limit. It has changed nothing at that point, since it only makes values until it returns, so the function fallback/2 runs the whole expression from the environment it was given with the interpreter's walk (Feel.Runtime.Interpret.run/3), whose answer is what the interpreter would have given: the diagnostics, the place where a limit ends the run, and the strict mode's error. The path is exact for the runs it answers because each of its steps is one the runtime takes, with the arguments the runtime would give it, and for the others because it does not answer.

Only the taken branch of an if constructs values, as in the interpreter. Each branch checks its own largest string, so an untaken branch cannot cause a size failure. The dialect of the expression is not read: nothing in the runtime reads it for an operation that gives a value, and the operations that would (the ones that give an error) are not the path's.

The IR that fallback/2 runs is a literal of the module, with the metadata of each node cut down to the span (and the error and the signature of a call, and boxed of a boxed if), which is all that the interpreter reads of the nodes of the subset: a module that has the whole IR is 100 to 200 µs longer to compile, and one that has it as a binary takes 1 to 3 µs to read it for each run that is not answered.

The compile

The function is nested one case deep for each operation that gives a number, with the names read at the start, so every name is alive until it is used, and the Erlang compiler takes more than linear time for a function of many of those (D-113): for arithmetic alone it takes 1.2 to 1.5 times as long as for the emitter's code for 4 to 32 nodes (a chain of + of 16 names is the worst), for comparisons 0.7 to 0.9 times, and for an if chain or a mix of operations about as long. The subset is therefore bounded, and an expression of more nodes than that keeps the code of the emitter, which Feel.Backend.Beam.Chunks cuts.

Summary

Functions

Returns the forms of run/2 and fallback/2 for an IR in the subset, or :error for one that is not.

Functions

emit(ir)

@spec emit(Feel.IR.t()) :: {:ok, [:erl_parse.abstract_form()]} | :error

Returns the forms of run/2 and fallback/2 for an IR in the subset, or :error for one that is not.

iex> ir = {:conform_bindings, %{span: {0, 5}}, :enforce, [{"x", {0, 1}, :number}],
...>   {:arithmetic, %{span: {0, 5}}, :add, {:var, %{span: {0, 1}, type: [:number, :null]}, "x"},
...>     {:const, %{span: {4, 5}, type: [:number]}, Decimal.new(1)}}}
iex> {:ok, [{:function, _, :run, 2, _}, {:function, _, :fallback, 2, _}]} = Feel.Backend.Beam.Scalar.emit(ir)
iex> Feel.Backend.Beam.Scalar.emit({:const, %{span: {0, 1}, type: [:number]}, Decimal.new(1)})
:error