Current section

Files

Jump to
explorer lib explorer remote.ex
Raw

lib/explorer/remote.ex

defmodule Explorer.Remote do
@moduledoc """
A module responsible for placing remote dataframes and
garbage collect them.
The functions in `Explorer.DataFrame` and `Explorer.Series`
will automatically move operations on remote dataframes to
the nodes they belong to. `Explorer` also integrates with
`FLAME` and automatically tracks remote dataframes and
series returned from `FLAME` calls when the `:track_resources`
option is enabled.
This module provides additional conveniences for manual placement.
## Implementation details
In order to understand what this module does, we need
to understand the challenges in working with remote series
and dataframes.
Series and dataframes are actually NIF resources: they are
pointers to blobs of memory operated by low-level libraries.
Those are represented in Erlang/Elixir as references (the
same as the one returned by `make_ref/0`). Once the reference
is garbage collected (based on refcounting), those NIF
resources are garbage collected and the memory is reclaimed.
When using Distributed Erlang, you may write this code:
remote_series = :erpc.call(node, Explorer.Series, :from_list, [[1, 2, 3]])
However, the code above will not work, because the series
will be allocated in the remote node and the remote node
won't hold a reference to said series! This means the series
is garbage collected and if we attempt to read it later on,
from the caller node, it will no longer exist. Therefore,
we must explicitly place these resources in remote nodes
by spawning processes to hold these references. That's what
the `place/2` function in this module does.
We also need to guarantee these resources are not kept
forever by these remote nodes, so `place/2` creates a
local NIF resource that notifies the remote resources
they have been GCed, effectively implementing a remote
garbage collector.
"""
@doc """
Receives a data structure and traverses it looking
for remote dataframes and series.
If any is found, it spawns a process on the remote node
and sets up a distributed garbage collector. This function
only traverses maps, lists, and tuples, it does not support
arbitrary structs (such as map sets).
It returns the updated term and a list of remote PIDs
spawned.
"""
def place(term, acc \\ []), do: place_on_node(term, acc)
defp place_on_node(term, acc) do
place(term, acc, &Explorer.Remote.Holder.start_child/2)
end
defp place_on_pid(term, remote_pid) do
place(term, [], fn _remote_ref, _local_gc ->
{:ok, remote_pid}
end)
end
defp place(tuple, acc, fun) when is_tuple(tuple) do
{list, acc} = tuple |> Tuple.to_list() |> place(acc, fun)
{List.to_tuple(list), acc}
end
defp place(list, acc, fun) when is_list(list) do
Enum.map_reduce(list, acc, &place(&1, &2, fun))
end
defp place(%Explorer.Backend.LazySeries{args: args} = lazy_series, acc, fun) do
{args, acc} = Enum.map_reduce(args, acc, &place(&1, &2, fun))
{%{lazy_series | args: args}, acc}
end
defp place(%Explorer.Series{data: %Explorer.Backend.LazySeries{} = data} = series, acc, fun) do
{data, acc} = place(data, acc, fun)
{%{series | data: data}, acc}
end
defp place(%Explorer.Series{data: %impl{}} = series, acc, fun) do
place_impl(impl, series, acc, fun)
end
defp place(%Explorer.DataFrame{data: %impl{}} = df, acc, fun) do
place_impl(impl, df, acc, fun)
end
defp place(%_{} = other, acc, _fun) do
{other, acc}
end
defp place(%{} = map, acc, fun) do
{pairs, acc} =
Enum.map_reduce(map, acc, fn {k, v}, acc ->
{k, acc} = place(k, acc, fun)
{v, acc} = place(v, acc, fun)
{{k, v}, acc}
end)
{Map.new(pairs), acc}
end
defp place(other, acc, _fun) do
{other, acc}
end
defp place_impl(impl, struct, acc, fun) do
with remote_ref when is_reference(remote_ref) and node(remote_ref) != node() <-
impl.owner_reference(struct),
false <- alive?(remote_ref, struct) do
{local_ref, remote_pid} = place_remote_ref(remote_ref, fun)
{%{struct | remote: {local_ref, remote_pid, remote_ref}}, [remote_pid | acc]}
else
_ ->
{struct, acc}
end
end
defp alive?(remote_ref, %{remote: {local_gc_ref, _, remote_ref}})
when is_reference(local_gc_ref) and node(local_gc_ref) == node(),
do: Explorer.Remote.LocalGC.alive?(local_gc_ref)
defp alive?(_remote_ref, _struct), do: false
defp place_remote_ref(remote_ref, fun) do
local_gc = Explorer.Remote.LocalGC.whereis!()
{:ok, remote_pid} = fun.(remote_ref, local_gc)
local_ref = Explorer.Remote.LocalGC.track(local_gc, remote_pid, remote_ref)
Explorer.Remote.Holder.hold(remote_pid, remote_ref, local_gc)
{local_ref, remote_pid}
end
@doc false
# Applies the given mod/fun/args in the remote node.
# If a pid is given, we assume the pid will hold those references
# and avoid spawning new ones. If a node is given, regular placement
# occurs.
def apply(pid, mod, fun, resources, args_callback, place?) when is_pid(pid) do
placing_callback = if place?, do: &place_on_pid(&1, pid), else: &{&1, []}
do_apply(node(pid), mod, fun, resources, args_callback, placing_callback)
end
def apply(node, mod, fun, resources, args_callback, place?) when is_atom(node) do
placing_callback = if place?, do: &place_on_node(&1, []), else: &{&1, []}
do_apply(node, mod, fun, resources, args_callback, placing_callback)
end
defp do_apply(node, mod, fun, resources, args_callback, placing_callback) do
resources =
Enum.map(resources, fn
{resource_data, resource_node} when resource_node == node or resource_node == nil ->
{:local, resource_data}
{%{data: %impl{}} = resource_data, resource_node} ->
{:remote, impl, owner_export(resource_node, impl, resource_data)}
end)
message_ref = :erlang.make_ref()
remote_args = [self(), message_ref, mod, fun, resources, args_callback]
child = Node.spawn_link(node, __MODULE__, :remote_apply, remote_args)
monitor_ref = Process.monitor(child)
receive do
{^message_ref, result} ->
Process.demonitor(monitor_ref, [:flush])
{term, _acc} = placing_callback.(result)
send(child, {message_ref, :ok})
term
{:DOWN, ^monitor_ref, _, _, reason} ->
exit(reason)
end
end
defp owner_export(resource_node, impl, resource_data) do
case :erpc.call(resource_node, impl, :owner_export, [resource_data]) do
{:ok, val} -> val
{:error, error} -> raise error
end
end
defp owner_import(impl, exported_data) do
case impl.owner_import(exported_data) do
{:ok, val} -> val
{:error, error} -> raise error
end
end
@doc false
def remote_apply(parent, message_ref, mod, fun, resources, args_callback) do
resources =
Enum.map(resources, fn
{:local, value} -> value
{:remote, impl, value} -> owner_import(impl, value)
end)
monitor_ref = Process.monitor(parent)
result = apply(mod, fun, args_callback.(resources))
send(parent, {message_ref, result})
receive do
{^message_ref, :ok} ->
Process.demonitor(monitor_ref, [:flush])
# Call a remote function to prevent garbage collection from happening until we are done
Explorer.Remote.LocalGC.identity(result)
{:DOWN, ^monitor_ref, _, _, reason} ->
exit(reason)
end
end
end
if Code.ensure_loaded?(FLAME.Trackable) do
defimpl FLAME.Trackable, for: [Explorer.DataFrame, Explorer.Series, Explorer.Backend.LazySeries] do
def track(data, acc, _node) do
Explorer.Remote.place(data, acc)
end
end
end