Skip to content

Commit

Permalink
stdlib: Add query optimisation to ets:fun2ms/1
Browse files Browse the repository at this point in the history
Unlike writing match specs directly, `ets:fun2ms/1` generates queries by
translating an erlang function expression. This is convenient and makes
for readable queries, but it necessarily trades-off some expressiveness
in favour of simplicity (for example, it's not possible to generate a
match spec pattern guard that matches against an in-scope variable:
users are forced to use something like an equality guard instead).
Here, we resolve that issue by reading the author's _intention_ from
the given function expression, generating the match spec as before via
`ms_transform`, but then running an optimisation pass over it during
compilation in order to generate more efficient queries.

Performance
===========

Amongst other things, we optimise equality guards by moving them into
the pattern, which can avoid scanning the whole table, making queries
`O(1)` or `O(log(n))` (depending on the table type), rather than `O(n)`,
(where `n` is the number of rows in the table). In other words, this is
not primarily a micro-optimisation, but rather a very substantial
algorithmic complexity improvement for many common queries.

In practice, I have seen no situations where the new `ets:fun2ms/1`
queries are slower, but many simple queries can be executed drastically
faster when the number of rows in the table is large.

For example, even a simple query over a table of a million rows made up
of pairs of keys and values queried with:

```erlang
make_query(Key) ->
  ets:fun2ms(fun({K, V}) when K =:= Key -> {K,V} end).
```

now executes **>1000x faster** with my local benchmarks. Almost any
query which requires that a `=:=` guard always hold will potentially see
a substantial performance improvement.

Theory
======

From the existing ETS match spec docs:
> Traversals using match and select functions may not need to scan the
> entire table depending on how the key is specified. A match pattern with
> a fully bound key (without any match variables) will optimize the
> operation to a single key lookup without any table traversal at all. For
> ordered_set a partially bound key will limit the traversal to only scan
> a subset of the table based on term order. A partially bound key is
> either a list or a tuple with a prefix that is fully bound.

We can leverage this knowledge to re-write queries to make better use of the key.

For example:

```erlang
make_query(Key) ->
  ets:fun2ms(fun({K, V}) when K =:= Key -> {K,V} end).
```

was previously compiled to:

```erlang
{
  {'$1', '$2'},
  [
    {'=:=', '$1', Key}
  ],
  [{'$1', '$2'}]
}
```

This was sub-optimal, since the equality guard is less efficient than
the functionally-equivalent pattern match because the equality guard did
not result in a fast lookup using the table's key.

Now, the same function expression is compiled to this, more efficient, query:

```erlang
{
  {Key, '$2'},
  [],
  [{Key, '$2'}]
}
```

We can also simplify constant parts of queries statically, and perform
other rewritings to improve efficiency, but the largest win comes from
inlining the values of variables bound by guards such as `(K =:= Key)`.

Implementation
==============

This optimisation is implemented for all relevant literals that I could
find. Floats were given extra consideration and testing because of the
differences in `==`/`=:=` vs. pattern matching.  In this situation, the
handling of floats in `ordered_set` is safe because we only inline `=:=`
guards into the the match head and body, but we leave `==` as a guard,
since determining statically whether the table type would make this a
safe operation or not is not feasible using the the information
available in the parse transform.

New unit tests cover the parse transform compiling to the expected match
expression, the match expression matching the expected rows, and the
equivalence between the naive match expression and the optimised one in
terms of data returned.  See the changes to `ms_transform_SUITE.erl` for
more information.

This optimisation is specifically applied in `ets:fun2ms/1`, because I
think users would expect generated match specs to avoid trivial
inefficiencies (and, indeed, utilising the key efficiently when it was
given as a parameter was impossible to express before). Moreover, by
making use of `ets:fun2ms/1`, users have already ceded some control of
the generated match spec to the tooling. Users who construct match specs
directly will be unaffected.

Notably, since `ets:fun2ms/1` is transformed at compile time (outside of
the shell, at least), we don't pay any unnecessary performance penalty
at runtime in order to apply these optimisations, and the cost of doing
them at compile time is low relative to other operations.

Later work could explore runtime query-planning for ETS, but avoiding
introducing performance regressions for at least some queries will be
harder to guarantee, since we then we would have to consider the runtime
cost of computing the optimisation itself.

Optimisation can be disabled with the `no_optimise_fun2ms` compiler flag,
but by default it is enabled. The flag can be altered via the usual
compile flag mechanisms, including the `-compile(no_optimise_fun2ms)`
attribute.
  • Loading branch information
TD5 committed Sep 4, 2023
1 parent 3064cbd commit 6403107
Show file tree
Hide file tree
Showing 6 changed files with 1,626 additions and 155 deletions.
2 changes: 1 addition & 1 deletion lib/compiler/src/compile.erl
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ forms(Forms) -> forms(Forms, ?DEFAULT_OPTIONS).

forms(Forms, Opts) when is_list(Opts) ->
do_compile({forms,Forms}, [binary|Opts++env_default_opts()]);
forms(Forms, Opt) when is_atom(Opt) ->
forms(Forms, Opt) when is_atom(Opt) orelse is_tuple(Opt) ->
forms(Forms, [Opt|?DEFAULT_OPTIONS]).

%% Given a list of compilation options, returns true if compile:file/2
Expand Down
5 changes: 4 additions & 1 deletion lib/stdlib/src/ets.erl
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,11 @@ fun2ms(ShellFun) when is_function(ShellFun) ->
%% Check that this is really a shell fun...
case erl_eval:fun_data(ShellFun) of
{fun_data,ImportList,Clauses} ->
{module, FunModule} = erlang:fun_info(ShellFun,module),
CompilationOptions = FunModule:module_info(compile),
ShouldOptimise = not proplists:get_bool(no_optimise_fun2ms, CompilationOptions),
case ms_transform:transform_from_shell(
?MODULE,Clauses,ImportList) of
?MODULE,Clauses,ImportList, ShouldOptimise) of
{error,[{_,[{_,_,Code}|_]}|_],_} ->
io:format("Error: ~ts~n",
[ms_transform:format_error(Code)]),
Expand Down
Loading

0 comments on commit 6403107

Please sign in to comment.