Feel.Backend.Beam (feel v0.1.0)

Copy Markdown View Source

The backend that compiles an expression to a BEAM module.

compile/2 emits the module with Feel.Backend.Beam.Emitter, loads it with Feel.Backend.Beam.Loader, and returns an executable that holds the module name. run/3 calls the generated function, which calls only Feel.Runtime and the built-in functions (Feel.Builtin). release/1 unloads the module, which costs time and does not free its atom (see there).

Compared with Feel.Backend.Interpreter it saves the walk over the IR, and for the expressions that only do scalar work on declared names (numbers, strings and booleans read from the bindings, arithmetic, comparisons, and, or, not, if, between, in of ranges) it saves much more: Feel.Backend.Beam.Scalar makes a function that reads the names in one pattern, does the operations without an environment, an outcome or repeated declared-type checks, checks the largest constructed string on the executed path against its per-value limit, and hands the run to the interpreter, from the start, whenever it is not sure of the answer (a null that the operation reports, a name that is not bound, a value of another kind, an overflow, a limit; D-164). Those run in 0.29 to 0.50 of the interpreter's time (price * quantity * (1 + tax) 0.50, three conditions joined by and 0.37, a between and a range 0.29, a chain of if 0.46; the interpreter takes 0.8 to 1.8 µs for them); a run that falls back takes what the interpreter takes, within 10 %. What is left to the code of Feel.Backend.Beam.Emitter (everything with a list, a context, a loop, a function, a call of a built-in function, a path or a date) saves the walk and is small: a mixed expression took about 6% less time. The time that is left in the fast path is the runtime's decimal arithmetic. It has operating costs: every distinct expression becomes a loaded module and an atom, neither of which the VM frees by itself, and the Erlang compiler takes time and memory in proportion to the code it is given (a source of the size :max_source_bytes allows took 8 to 18 s and 0.6 to 1.4 GB). For expressions that come from sources that are not trusted, or that are run only a few times, use the interpreter. If this backend is used for them, :max_generated_statements of Feel.Limits (10 000 by default) refuses an expression that would make more code than that, before the compiler is run (D-154, compile/2); it bounds the compile of one expression, and not the modules and atoms that many distinct ones leave, nor how many are compiled at once, which the caller bounds. A separate VM-lifetime quota caps fresh module names at 50 000 by default (:feel, :max_generated_modules). See Feel.Backend.Beam.Loader for how modules are named and retired. Feel.Runtime.abi/0 supplies a cache stamp for generated names. Prepared expressions must be regenerated on upgrade; persisted generated modules are unsupported.

It compiles every node that the analyzer produces, unary tests included, and raises ArgumentError for any other node. Its results are the interpreter's, and a test runs both on the same expressions.

Summary

Functions

Compiles and loads the IR as a module.

Unloads the module of an executable.

Runs the generated function of an executable on a set of bindings.

Types

t()

@type t() :: %Feel.Backend.Beam{
  dialect: Feel.Dialect.t(),
  env: Feel.Runtime.Env.t() | nil,
  extensions: Feel.Extensions.t(),
  module: module()
}

Functions

compile(ir, options)

@spec compile(Feel.Backend.ir(), [Feel.Backend.compile_option()]) ::
  {:ok, t()} | {:error, Feel.Error.t()}

Compiles and loads the IR as a module.

It emits the forms with Feel.Backend.Beam.Emitter.emit/2, names the module with Feel.Backend.Beam.Loader.module_name/1 once the limit has let the forms through, and loads them with Feel.Backend.Beam.Loader.load/2. An IR that is already loaded as a module is not loaded again, so preparing one expression twice costs one module. An expression that is refused makes no module and no atom when refused by a generation or module-name limit. A later compilation or loading failure can leave the name interned.

Returns an error of kind :backend when the module cannot be built or loaded (D-38), and one of kind :limit with the code :too_large_to_compile when the forms have more statements than :max_generated_statements of the limits allows (D-154). Code :too_many_generated_modules reports the exhausted VM-lifetime name quota or atom-table headroom. The generation limit is found before the Erlang compiler is run, which is where the work and the memory are: the compile of the longest source takes 8 to 18 s and 0.6 to 1.4 GB, and the emit 0.6 s. A caller can run the same source with the interpreter.

Options

The options of Feel.Backend.compile_option/0. The profile :explain is not implemented yet and raises ArgumentError (Feel.Backend.validate_profile!/1). The :dialect and the :extensions are kept in the executable and not in the module, which is the same for every dialect and every set of extensions (D-49, D-81): the module is named by its IR, and a call of a function finds its function through the environment.

iex> ir = {:const, %{span: {0, 1}, type: :number}, Decimal.new(42)}
iex> {:ok, executable} = Feel.Backend.Beam.compile(ir, [])
iex> Feel.Backend.Beam.run(executable, %{}, [])
{:ok, Decimal.new(42), []}

release(beam)

@spec release(t()) :: :ok | {:error, :in_use}

Unloads the module of an executable.

Returns {:error, :in_use} while a process is still running its code; try again later. A released executable cannot be run again; prepare the expression again, and note that executables made from the same expression share one module, so releasing one releases it for all.

What a release costs

Time, in proportion to the number of processes in the VM. The code is deleted and then purged, and a purge asks every process whether it still runs the code. It runs in the code server, so every other call to the code server, a load of another module included, waits for it. One release took this long with this many processes besides the 104 of an idle mix run (OTP 29.1.1, 10 schedulers, on a machine at a load average of 55 to 90, so a quiet machine is faster; the ranges are those of two runs of 15 and 40 releases each):

ProcessesLeastMedian
01.2 to 1.7 ms3 to 6 ms
3001.4 to 2.2 ms10 ms
3,0005 to 7 ms11 to 16 ms
30,00064 to 73 ms81 to 99 ms
100,000255 to 288 ms337 to 479 ms

That is 2 to 5 µs for each process. These are processes that wait with a small heap; a process with a large heap costs more (3,000 processes of about 80 KB each took 19 to 20 ms at least and 48 to 59 ms at the median). A call to the code server made 8 ms after a release had started waited 5.5, 46 and 171 ms (medians) for it, with 3,000, 30,000 and 100,000 processes. Release an expression when it is discarded and not after each run, and keep the executable while it can be run again: preparing the same expression finds the module loaded.

The atom, which is never freed. The name of the module is an atom, and the VM frees no atom, so a release gives back the code and not the name or lifetime quota. Each distinct expression costs one atom (200 distinct expressions of each of four shapes took 200 atoms, and releasing them all gave back none); the same expression prepared again costs none. The table holds 1,048,576 atoms, and when it is full the VM stops, not only the expression. The default lifetime quota stops new name creation at 50 000, before that table fills from generated names alone; other host code can still consume atoms. Only a VM restart reclaims the atoms and resets this quota. Use the interpreter for unbounded expression streams and bound concurrent compilation and retained code when choosing BEAM.

run(executable, bindings, options)

Runs the generated function of an executable on a set of bindings.

Takes the run options of Feel.run/3 and raises ArgumentError for any other, and for an executable that has been released.