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
@type t() :: %Feel.Backend.Beam{ dialect: Feel.Dialect.t(), env: Feel.Runtime.Env.t() | nil, extensions: Feel.Extensions.t(), module: module() }
Functions
@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), []}
@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):
| Processes | Least | Median |
|---|---|---|
| 0 | 1.2 to 1.7 ms | 3 to 6 ms |
| 300 | 1.4 to 2.2 ms | 10 ms |
| 3,000 | 5 to 7 ms | 11 to 16 ms |
| 30,000 | 64 to 73 ms | 81 to 99 ms |
| 100,000 | 255 to 288 ms | 337 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.
@spec run(t(), Feel.bindings(), [Feel.run_option()]) :: Feel.result(Feel.Value.t())
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.