Current section
Files
Jump to
Current section
Files
lib/nx/defn/evaluator.ex
defmodule Nx.Defn.Evaluator do
@moduledoc """
The default implementation of a `Nx.Defn.Compiler`
that evaluates the expression tree against the
tensor backend.
## Options
The following options are specific to this compiler:
* `:garbage_collect` - when true, garbage collects
after evaluating each node
* `:max_concurrency` - the number of partitions to
start when running a `Nx.Serving` with this compiler
"""
@behaviour Nx.Defn.Compiler
alias Nx.Defn.{Composite, Expr, Tree}
@creation_ops [:eye, :iota, :from_binary]
@list_ops [:concatenate, :stack]
@indices_ops [:slice, :put_slice]
@impl true
def __partitions_options__(opts) do
List.duplicate(opts, Keyword.get(opts, :max_concurrency, 1))
end
@impl true
def __to_backend__(_opts) do
Nx.default_backend()
end
@impl true
def __jit__(key, vars, fun, args_list, opts) do
__compile__(key, vars, fun, opts).(args_list)
end
@impl true
def __compile__(_key, vars, fun, opts) do
hooks = Keyword.get(opts, :hooks, %{})
gc? = Keyword.get(opts, :garbage_collect, false)
{expr, output, cache} = precompile(fun, vars, hooks)
fn [params] ->
state = %{
params: params,
gc: gc?,
hooks: hooks
}
[expr |> composite_eval(state, [cache]) |> apply_output(output)]
end
end
@impl true
def __shard_jit__(_key, _mesh, _vars, _fun, _args_list, _opts) do
raise "sharding is not supported by Nx.Defn.Evaluator"
end
defp apply_output({result, _cache}, output) do
{result, []} =
Composite.traverse(result, output, fn result, [out | acc] ->
{%{out | data: result.data}, acc}
end)
result
end
defp precompile(fun, vars, hooks) do
{expr, output} =
vars
|> fun.()
|> Composite.traverse([], &{Nx.devectorize(&1), [Nx.to_template(&1) | &2]})
state = %{hooks: hooks, parent_ids: nil, current_ids: nil}
{expr, cache} = init_compute_cache(expr, state)
{expr, Enum.reverse(output), cache}
end
defp init_compute_cache(expr, state) do
state = %{state | parent_ids: %{}, current_ids: Tree.scope_ids(expr, %{})}
composite_compute_cache(expr, state, %{})
end
defp composite_compute_cache(expr, state, cache) do
Composite.traverse(expr, cache, &compute_cache(&1, state, &2))
end
defp compute_cache(%Nx.Tensor{data: %Expr{op: op}} = tensor, _state, cache)
when op in [:constant, :tensor] do
{tensor, cache}
end
defp compute_cache(%Nx.Tensor{data: %Expr{op: :metadata, args: [expr, _meta]}}, state, cache) do
composite_compute_cache(expr, state, cache)
end
defp compute_cache(%Nx.Tensor{data: %Expr{id: id, op: op}} = tensor, state, cache) do
cache =
case state.parent_ids do
# If the id exists in the parent, the parent will compute it.
%{^id => _} ->
Map.put_new(cache, id, tensor)
%{} ->
case cache do
%{^id => {:args, counter, args_or_placeholder}} ->
%{cache | id => {:args, counter + 1, args_or_placeholder}}
%{} ->
cache = Map.put(cache, id, {:args, 1, nil})
{args, cache} = compute_cache(op, tensor, state, cache)
Map.update!(cache, id, fn {:args, counter, _} -> {:args, counter, args} end)
end
end
{put_in(tensor.data.args, nil), cache}
end
defp compute_cache(:fun, %{data: %Expr{args: args}}, state, cache) do
[args, expr, _mfa] = args
{expr, expr_cache} = init_compute_cache(expr, state)
{[length(args), expr, expr_cache], cache}
end
defp compute_cache(:while, %{data: %Expr{args: args}}, state, cache) do
[initial, _arg, pred, block] = args
{initial, cache} = composite_compute_cache(initial, state, cache)
{{pred, block}, while_cache} = init_compute_cache({pred, block}, state)
{[initial, pred, block, while_cache], cache}
end
defp compute_cache(:optional, %{data: %Expr{args: args}}, state, cache) do
[call, expr, _callback] = args
%{data: %{args: call_args, op: call_name}} = call
{call_prefix, call_suffix} = Enum.split_while(call_args, &(not is_list(&1)))
{call_prefix, cache} = Enum.map_reduce(call_prefix, cache, &compute_cache(&1, state, &2))
call_args = call_prefix ++ call_suffix
key = computation_key(call_name, call_args)
{{expr, expr_cache}, cache} =
case cache do
%{^key => optional_expr_cache} ->
{optional_expr_cache, cache}
%{} ->
optional_expr_cache = init_compute_cache(expr, state)
{optional_expr_cache, Map.put(cache, key, optional_expr_cache)}
end
call = put_in(call.data.args, call_args)
{[call, expr, expr_cache], cache}
end
defp compute_cache(:cond, %{data: %Expr{args: [clauses, last]}}, state, cache) do
%{parent_ids: parent_ids, current_ids: current_ids} = state
clause_caches =
Enum.map([last | clauses], fn clause ->
state = %{
state
| parent_ids: current_ids,
current_ids: Tree.scope_ids(clause, current_ids)
}
composite_compute_cache(clause, state, %{})
end)
# Now, for each cache, split the IDs from parents from the actual cond IDs
{[last_cache | clauses_cache], {all_ids, cache}} =
Enum.map_reduce(clause_caches, {%{}, cache}, fn {clause, clause_cache}, seen_ids_cache ->
{clause_cache, seen_ids_cache} =
Enum.flat_map_reduce(clause_cache, seen_ids_cache, fn
{id, %_{} = tensor}, {seen_ids, cache} ->
case seen_ids do
# We have already processed this id for the whole cond
%{^id => _} ->
{[], {seen_ids, cache}}
# The ID belongs to our own parents
%{} when is_map_key(parent_ids, id) ->
{[], {seen_ids, Map.put_new(cache, id, tensor)}}
# The ID belongs to us
%{} ->
{_, cache} = composite_compute_cache(tensor, state, cache)
{[], {Map.put(seen_ids, id, true), cache}}
end
{id, counter}, seen_ids_cache ->
{[{id, counter}], seen_ids_cache}
end)
{{clause, Map.new(clause_cache)}, seen_ids_cache}
end)
{[clauses_cache, last_cache, Map.keys(all_ids)], cache}
end
defp compute_cache(:token, %{data: %Expr{args: [token]}}, state, cache) do
hooks = state.hooks
{exprs_hooks, cache} =
Enum.flat_map_reduce(token.hooks, cache, fn
%{callback: callback, expr: expr, name: name}, cache ->
hook_fun = hooks[name] || callback
cond do
hook_fun ->
{expr, cache} = composite_compute_cache(expr, state, cache)
{[{expr, hook_fun}], cache}
Tree.has_hooks?(expr, hooks) ->
{expr, cache} = composite_compute_cache(expr, state, cache)
{[{expr, nil}], cache}
true ->
{[], cache}
end
end)
{[exprs_hooks], cache}
end
defp compute_cache(_op, tensor, state, cache) do
Tree.apply_args(tensor, cache, &compute_cache(&1, state, &2))
end
defp computation_key(op, args) do
keys =
Enum.map(args, fn
%Nx.Tensor{shape: shape, names: names, type: type} -> {type, shape, names}
opts -> opts
end)
{op, keys}
end
## Evaluation
defp eval(%Nx.Tensor{data: %Expr{op: :tensor, args: [t]}}, _state, caches) do
{t, caches}
end
defp eval(%Nx.Tensor{data: %Expr{op: :constant, args: [constant]}} = ans, _state, caches) do
{backend, backend_options} = Nx.default_backend()
{backend.constant(ans, constant, backend_options), caches}
end
defp eval(%Nx.Tensor{data: %Expr{op: op, id: id}} = ans, state, [cache | caches]) do
case cache do
%{^id => {:args, count, args}} ->
{res, [cache | caches]} = eval_apply(op, args, ans, state, [cache | caches])
state.gc && :erlang.garbage_collect(self())
{res, [decrement_cache(cache, id, count, res) | caches]}
%{^id => {:result, count, res}} ->
{res, [decrement_cache(cache, id, count, res) | caches]}
%{} ->
# If we don't find the tensor in the current scope,
# it may be in a parent scope, so look up. If we find
# it in the parent, we don't decrement it, as that will
# be done at the end of processing the current scope.
eval_parent(caches, id, op, ans, state, [cache])
end
end
defp decrement_cache(cache, id, 1, _res), do: Map.delete(cache, id)
defp decrement_cache(cache, id, counter, res), do: %{cache | id => {:result, counter - 1, res}}
defp eval_parent([cache | caches], id, op, ans, state, acc) do
case cache do
%{^id => {:result, _count, res}} ->
{res, Enum.reverse(acc, [cache | caches])}
%{^id => {:args, count, args}} ->
{res, [cache | caches]} = eval_apply(op, args, ans, state, [cache | caches])
state.gc && :erlang.garbage_collect(self())
{res, Enum.reverse(acc, [Map.put(cache, id, {:result, count, res}) | caches])}
%{} ->
eval_parent(caches, id, op, ans, state, [cache | acc])
end
end
defp eval_parent([], id, op, _ans, _state, _acc) do
raise "trying to read evaluator cache that has expired for OP=#{op} ID=#{inspect(id)}\n\n" <>
"Please report this bug with the relevant code that triggers it: https://github.com/elixir-nx/nx"
end
defp decrement_parents([cache | caches], id) do
case cache do
%{^id => {:result, count, value}} -> [decrement_cache(cache, id, count, value) | caches]
%{^id => {:args, count, args}} -> [%{cache | id => {:args, count - 1, args}} | caches]
%{} -> [cache | decrement_parents(caches, id)]
end
end
defp eval_apply(:parameter, [i], _ans, state, caches) do
case Enum.fetch!(state.params, i).() do
%Nx.Tensor{data: %Nx.Defn.Expr{}} = tensor ->
raise ArgumentError,
"cannot pass a tensor expression as argument to defn, got: #{inspect(tensor)}"
%Nx.Tensor{} = tensor ->
{Nx.devectorize(tensor), caches}
end
end
defp eval_apply(:elem, [tuple, i], _ans, state, caches) do
{tuple, caches} = composite_eval(tuple, state, caches)
{elem(tuple, i), caches}
end
defp eval_apply(:attach_token, [token, expr], _ans, state, caches) do
{_, caches} = eval(token, state, caches)
eval(expr, state, caches)
end
defp eval_apply(:fun, [length, expr, expr_cache], _ans, state, caches) do
fun =
case length do
1 ->
fn arg1 ->
params = [fn -> Nx.to_tensor(arg1) end]
{result, _} = composite_eval(expr, %{state | params: params}, [expr_cache])
result
end
2 ->
fn arg1, arg2 ->
params = [fn -> Nx.to_tensor(arg1) end, fn -> Nx.to_tensor(arg2) end]
{result, _} = composite_eval(expr, %{state | params: params}, [expr_cache])
result
end
end
{fun, caches}
end
defp eval_apply(:cond, [clauses_cache, last_cache, parent_ids], _ans, state, caches) do
{chosen, chosen_cache} = cond_clause(clauses_cache, last_cache, state, caches)
{res, [_ | caches]} = composite_eval(chosen, state, chosen_cache)
caches = Enum.reduce(parent_ids, caches, &decrement_parents(&2, &1))
{res, caches}
end
defp eval_apply(:while, [initial, pred, block, while_cache], _ans, state, caches) do
{initial, caches} = composite_eval(initial, state, caches)
{while(initial, pred, block, state, [while_cache]), caches}
end
defp eval_apply(:token, [exprs_hooks], _ans, state, caches) do
caches =
List.foldr(exprs_hooks, caches, fn {expr, hook_fun}, caches ->
{res, caches} = composite_eval(expr, state, caches)
hook_fun && hook_fun.(res)
caches
end)
{{}, caches}
end
defp eval_apply(:optional, [call, expr, expr_cache], _ans, state, caches) do
{args, caches} = Tree.apply_args(call, caches, &eval(&1, state, &2))
backend = Nx.Shared.list_impl!(args)
if function_exported?(backend, call.data.op, length(args) + 1) do
out =
case call do
%{type: {:tuple, _}} -> expr
_ -> call
end
{apply(backend, call.data.op, [out | args]), caches}
else
params = Enum.map(args, &fn -> &1 end)
{res, _} = composite_eval(expr, %{state | params: params}, [expr_cache])
{res, caches}
end
end
defp eval_apply(:runtime_call, [expr, fun, out_template], _ans, state, caches) do
{tensor_value, caches} = composite_eval(expr, state, caches)
result = fun.(tensor_value)
if not Nx.compatible?(out_template, result) do
raise "expected the runtime_call function to match the given output template"
end
case out_template do
%Nx.Tensor{} -> {result, caches}
_ -> {[result] |> Composite.flatten_list() |> List.to_tuple(), caches}
end
end
defp eval_apply(op, args, ans, state, caches) do
ans = put_in(ans.data.args, args)
{args, caches} = Tree.apply_args(ans, caches, &eval(&1, state, &2))
{mod, args} =
cond do
op in @creation_ops ->
{backend, backend_options} = Nx.default_backend()
{backend, [ans | args] ++ [backend_options]}
op in @list_ops ->
{Nx.Shared.list_impl!(hd(args)), [ans | args]}
op in @indices_ops ->
[tensor, indices | _] = args
{Nx.Shared.list_impl!([tensor | indices]), [ans | args]}
match?({:tuple, _}, ans.type) ->
{Nx.Shared.list_impl!(args), args}
true ->
{Nx.Shared.list_impl!(args), [ans | args]}
end
{apply(mod, op, args), caches}
end
## Control flow helpers
defp while(acc, condition, block, state, caches) do
state = %{state | params: composite_to_params(acc)}
{pred, temp} = eval(condition, state, caches)
if Nx.to_number(pred) != 0 do
{acc, _} = composite_eval(block, state, temp)
while(acc, condition, block, state, caches)
else
acc
end
end
defp cond_clause([{{pred, body}, cache} | clauses], last_cache, state, caches) do
{pred, pred_caches} = eval(pred, state, [cache | caches])
if Nx.to_number(pred) != 0,
do: {body, pred_caches},
else: cond_clause(clauses, last_cache, state, caches)
end
defp cond_clause([], {last, last_cache}, _state, caches) do
{last, [last_cache | caches]}
end
## Composite
defp composite_eval(composite, state, caches) do
Composite.traverse(composite, caches, &eval(&1, state, &2))
end
defp composite_to_params(composite) do
composite |> composite_to_params([]) |> Enum.reverse()
end
defp composite_to_params(tuple, acc) when is_tuple(tuple) do
Enum.reduce(Tuple.to_list(tuple), acc, &composite_to_params/2)
end
defp composite_to_params(other, acc) do
[fn -> other end | acc]
end
end