defmodule BatchServing do @moduledoc """ BatchServing batches work submitted by concurrent callers and executes it as a single request based on batch size and timeout limits. You can execute a serving inline with `inline/2`, or start a serving process and submit requests with `dispatch/2` for transparent cross-caller batching. Callbacks: * `map_inputs/2` - map caller input into a list of values (or stream of values) * `map_results/2` - map serving output into caller-facing result """ alias __MODULE__ @doc false @enforce_keys [:module, :arg] defstruct [ :module, :arg, :map_inputs, :map_results, :streaming, :batch_size, distributed_postprocessing: &Function.identity/1, process_options: [], runtime_options: [] ] @type mapped_input() :: list() | Enumerable.t(term()) @type map_inputs() :: (term() -> mapped_input()) @type map_results() :: (term() -> term()) @type distributed_preprocessing() :: (term() -> term()) @type distributed_postprocessing() :: (term() -> term()) @type t :: %__MODULE__{ module: atom(), arg: term(), map_inputs: map_inputs(), map_results: map_results(), distributed_postprocessing: distributed_postprocessing(), process_options: keyword(), runtime_options: keyword(), streaming: nil | %{hooks: [atom()]}, batch_size: nil | pos_integer() } @process_keys [ :batch_size, :batch_timeout, :partitions, :shutdown, :hibernate_after, :spawn_opt ] @doc """ The callback used to initialize the serving. The first argument reveals if the serving is executed inline, such as by calling `inline/2`, or started in a serving process. The second argument is the serving argument given to `new/2`. The third argument is a list of runtime options for each partition. It must return `{:ok, state}`, where the `state` can be any term. """ @callback init(type :: :inline | :process, arg :: term(), [runtime_options :: keyword]) :: {:ok, state :: term()} @doc """ Receives a batch, a partition, and returns a function to execute the batch. In case of serving processes, the function is executed is an separate process. """ @callback handle_batch(BatchServing.Batch.t(), partition :: non_neg_integer(), state) :: {:execute, (-> term()), state} when state: term() def create_serving_process_group_spec() do %{id: BatchServing.PG, start: {:pg, :start_link, [BatchServing.PG]}} end @doc """ Creates a new function serving. It expects a one-arity function that receives a list of values. """ def new(function, runtime_options \\ []) def new(function, runtime_options) when is_function(function, 1) and is_list(runtime_options) do new(BatchServing.Default, function, runtime_options) end def new(module, arg) when is_atom(module) do new(module, arg, []) end @doc """ Sets the batch size for this serving. This batch size is used to split batches given to both `inline/2` and `dispatch/2`, enforcing that the batch size never goes over a limit. If you only want to batch within the serving process, you must set `:batch_size` via `process_options/2` (or on `start_link/1`). > #### Why batch on `inline/2`? {: .info} > > By default, `inline/2` does not place a limit on its input size. It always > processes inputs directly within the current process. On the other hand, > `dispatch/2` always sends your input to a separate process, which > will batch and execute the serving only once the batch is full or a > timeout has elapsed. > > However, in some situations, an input given to `inline/2` needs to be > broken into several batches. If we were to very large batches to our > computation, the computation could require too much memory. In such > cases, setting a batch size even on `inline/2` is beneficial, because > BatchServing takes care of splitting a large batch into smaller ones > that do not exceed the `batch_size` value. """ def batch_size(%BatchServing{} = serving, batch_size) when batch_size > 0 do %{serving | batch_size: batch_size} end @doc """ Creates a new module-based serving. It expects a module and an argument that is given to its `init` callback. A third optional argument called `runtime_options` are additional runtime options passed to the module. """ def new(module, arg, runtime_options) when is_atom(module) and is_list(runtime_options) do runtime_options = Keyword.merge(BatchServing.default_options(), runtime_options) %BatchServing{module: module, arg: arg, runtime_options: runtime_options} end @doc """ Sets the input mapping function. The default implementation: * treats list input as one batch of values * treats non-list enumerables (such as streams) as a stream of values * wraps any other term into a single-item list """ def map_inputs(%BatchServing{} = serving, function) when is_function(function, 1) or is_nil(function) do %{serving | map_inputs: function} end @doc """ Sets the result mapping function. """ def map_results(%BatchServing{} = serving, function) when is_function(function, 1) or is_nil(function) do %{serving | map_results: function} end @doc """ Sets the distributed postprocessing function. The default implementation is `Function.identity/1`. """ def distributed_postprocessing(%BatchServing{} = serving, function) when is_function(function, 1) do %{serving | distributed_postprocessing: function} end @doc """ Configure the serving to stream its results. Once `inline/2` or `dispatch/2` are invoked, it will then return a stream. The stream must be consumed in the same process that calls `inline/2` or `dispatch/2`. Batches will be streamed as they arrive. You may also opt-in to stream `runtime` hooks. ## Options * `:hooks` - a list of hook names that will become streaming events ## Implementation details ### Result mapping Once streaming is enabled, the result mapping callback will receive a stream which will emit events for each hook in the shape of: {hook_name, term()} The stream will also receive events in the shape of `{:batch, output}` as batches are processed by the serving. The result mapping function is often expected to call `Stream.transform/3` to process those events into something usable by callers. If the `:hooks` option is given, only a single `:batch` event is emitted, at the end, as detailed next. ### Batch limits If you are streaming hooks, the serving server can no longer break batch and you are unable to push a payload bigger than `:batch_size`. For example, imagine you have a `batch_size` of 3 and you push three batches of two elements (AA, BB, and CC). Without hooks, the batches will be consumed as: AAB -> BCC With streaming, we can't break the batch `BB`, as above, so we will consistently pad with zeroes: AA0 -> BB0 -> CC0 In practice, this should not be a major problem, as you should generally avoid having a batch size that is not a multiple of the most common batches. """ def streaming(%BatchServing{} = serving, opts \\ []) do hooks = Keyword.get(opts, :hooks, []) if serving.streaming do raise ArgumentError, "serving is already marked as streaming" end %{serving | streaming: %{hooks: hooks}} end @doc """ Sets the process options of this serving. These are the same options as supported on `start_link/1`, except `:name` and `:serving` itself. """ def process_options(%BatchServing{} = serving, opts) when is_list(opts) do %{serving | process_options: Keyword.validate!(opts, @process_keys)} end @doc """ Sets runtime options for this serving. """ def runtime_options(%BatchServing{} = serving, runtime_options) when is_list(runtime_options) do %{serving | runtime_options: runtime_options} end def default_options() do [] end @doc """ Runs `serving` for a single item inline with the current process. """ def inline(%BatchServing{} = serving, item) do [result] = do_run(serving, item, :single) result end @doc """ Runs `serving` for explicit batch input inline with the current process. """ def inline_many(%BatchServing{} = serving, batch_input) when is_list(batch_input) do do_run(serving, batch_input, :batch) end def inline_many(%BatchServing{} = serving, %Stream{} = batch_input) do do_run(serving, batch_input, :batch) end defp do_run(%BatchServing{} = serving, input, mode) do %{ module: module, arg: arg, map_inputs: preprocessing, map_results: postprocessing, runtime_options: runtime_options, streaming: streaming, batch_size: limit } = serving batch_or_stream = handle_preprocessing(preprocessing, input, mode) {pid_ref, runtime_options} = run_streaming(streaming, runtime_options, batch_or_stream, limit) stream = run_batch_or_stream(batch_or_stream, limit) execution_result = case pid_ref do {pid, ref} -> send(pid, {ref, module, arg, runtime_options, stream}) receive_stream("inline/2", ref, :unknown) nil -> stream |> Enum.map_reduce(nil, fn %BatchServing.Batch{size: size} = batch, cache -> {:ok, state} = cache || handle_init(module, :inline, arg, [runtime_options]) {{run_execute(batch, module, state), size}, {:ok, state}} end) |> elem(0) |> Enum.map(&elem(&1, 0)) |> case do [single] -> single all -> all end end handle_postprocessing(postprocessing, execution_result) end defp run_streaming(nil, runtime_options, _batch_or_stream, _limit), do: {nil, runtime_options} defp run_streaming(%{hooks: []}, runtime_options, _batch_or_stream, _limit), do: {run_streaming(), runtime_options} defp run_streaming(%{hooks: hooks}, runtime_options, batch_or_stream, limit) do size = case batch_or_stream do %BatchServing.Batch{size: size} -> if limit == nil or size <= limit do size else raise ArgumentError, "batch size (#{size}) cannot exceed BatchServing server batch size of #{limit} when streaming hooks" end _ -> raise ArgumentError, "streaming hooks do not support input streaming; map_inputs must produce a single value list" end {pid, ref} = run_streaming() runtime_options = update_in(runtime_options[:hooks], fn acc -> Enum.reduce(hooks, acc || %{}, fn hook, acc -> Map.put(acc, hook, &run_hook(ref, size, &1, hook)) end) end) {{pid, ref}, runtime_options} end defp run_streaming do pid = spawn_link(fn -> receive do {ref, module, arg, runtime_options, stream} -> Enum.reduce(stream, {0, nil}, fn %BatchServing.Batch{size: size} = batch, {start, cache} -> {:ok, state} = cache || handle_init(module, :inline, arg, [runtime_options]) output = run_execute(batch, module, state) send(ref, {ref, {:batch, {0, size, output}}}) {start + size, {:ok, state}} end) end end) # {pid, Process.monitor(pid, alias: :demonitor)} {pid, :erlang.monitor(:process, pid, alias: :demonitor)} end defp run_hook(ref, size, result, hook) do send(ref, {ref, {:hook, {0, size, result, hook}}}) end defp run_batch_or_stream(%BatchServing.Batch{size: size} = batch, limit) when is_nil(limit) or size < limit do [batch] end defp run_batch_or_stream(%BatchServing.Batch{} = batch, limit) do Stream.unfold(batch, fn %BatchServing.Batch{size: size} = batch when size > limit -> BatchServing.Batch.split(batch, limit) %BatchServing.Batch{} = batch -> {batch, :done} :done -> nil end) end defp run_batch_or_stream(stream, limit) do Stream.each(stream, fn %BatchServing.Batch{size: size} when is_nil(limit) or size <= limit -> :ok other -> raise "mapped input produced an invalid batch" <> if(limit, do: " of maximum size #{limit}", else: "") <> ", got: #{inspect(other)}" end) end defp run_execute(batch, module, state) do {:execute, function, _} = handle_batch(module, batch, 0, state) :telemetry.span([:batch_serving, :serving, :execute], %{module: module}, fn -> output = handle_executed(module, function.()) {output, %{module: module}} end) end ## Process API @spec child_spec(maybe_improper_list()) :: %{ id: atom(), start: {BatchServing, :start_link, [maybe_improper_list(), ...]}, type: :supervisor } @doc false def child_spec(opts) when is_list(opts) do name = opts[:name] if name == nil or not is_atom(name) do raise ArgumentError, ":name option is expected when starting BatchServing and must be an atom" end opts[:serving] || raise ArgumentError, ":serving option is expected when starting Serving" %{ id: name, start: {__MODULE__, :start_link, [opts]}, type: :supervisor } end @doc """ Starts a `BatchServing` process to batch requests for a given serving. ## Options All options, except `:name` and `:serving`, can also be set via `process_options/2`. * `:name` - an atom with the name of the process * `:serving` - a `BatchServing` struct with the serving configuration * `:batch_size` - the maximum batch size. A default value can be set with `batch_size/2`, which applies to both `inline/2` and `dispatch/2`. Setting this option only affects `dispatch/2` and it defaults to `1` if none is set. * `:batch_timeout` - the maximum time to wait, in milliseconds, before executing the batch (defaults to `100`ms) * `:partitions` - The number of partitions (defaults to `1`) * `:shutdown` - the maximum time for the serving to shutdown. This will block until the existing computation finishes (defaults to `30_000`ms) * `:hibernate_after` and `:spawn_opt` - configure the underlying serving workers (see `GenServer.start_link/3`) """ def start_link(opts) do opts = Keyword.validate!(opts, [:name, :serving] ++ @process_keys) name = Keyword.fetch!(opts, :name) serving = Keyword.fetch!(opts, :serving) opts = Keyword.merge(serving.process_options, opts) serving_batch_size = serving.batch_size opts_batch_size = opts[:batch_size] batch_size = if serving_batch_size && opts_batch_size && serving_batch_size != opts_batch_size do raise ArgumentError, "the batch size set via BatchServing.batch_size/2 (#{serving_batch_size}) " <> "does not match the batch size given to the serving process (#{opts_batch_size})" else serving_batch_size || opts_batch_size || 1 end shutdown = Keyword.get(opts, :shutdown, 30_000) partitions = Keyword.get(opts, :partitions, 1) batch_timeout = Keyword.get(opts, :batch_timeout, 100) process_options = Keyword.take(opts, [:name, :hibernate_after, :spawn_opt]) supervisor = Module.concat(name, "Supervisor") task_supervisor = Module.concat(name, "TaskSupervisor") arg = {name, serving, partitions, batch_size, batch_timeout, task_supervisor} children = [ {Task.Supervisor, name: task_supervisor}, %{ id: __MODULE__, start: {GenServer, :start_link, [__MODULE__, arg, process_options]}, shutdown: shutdown } ] Supervisor.start_link(children, strategy: :one_for_all, max_restarts: 0, name: supervisor) end @doc """ Runs a single item on the serving process given by `name`. """ def dispatch(name, input, distributed_preprocessing \\ &Function.identity/1) def dispatch(name, input, distributed_preprocessing) when is_atom(name) and not is_list(input) do [v] = if pid = Process.whereis(name) do local_batched_run!(pid, name, input, :single) else distributed_batched_run!(name, input, distributed_preprocessing, :single) end v end def dispatch(name, _input, _distributed_preprocessing) when is_atom(name) do raise ArgumentError, "dispatch/3 accepts a single item; use dispatch_many/3 for explicit batches" end def dispatch({:local, name}, input, _distributed_preprocessing) when is_atom(name) and not is_list(input) do pid = Process.whereis(name) || exit({:noproc, {__MODULE__, :local_batched_run, [name, input]}}) [v] = local_batched_run!(pid, name, input, :single) v end def dispatch({:local, _name}, _input, _distributed_preprocessing) do raise ArgumentError, "dispatch/3 accepts a single item; use dispatch_many/3 for explicit batches" end def dispatch({:distributed, name}, input, distributed_preprocessing) when is_atom(name) and not is_list(input) do [v] = distributed_batched_run!(name, input, distributed_preprocessing, :single) v end def dispatch({:distributed, _name}, _input, _distributed_preprocessing) do raise ArgumentError, "dispatch/3 accepts a single item; use dispatch_many/3 for explicit batches" end @doc """ Runs explicit batch input on the serving process given by `name`. """ def dispatch_many!(name, batch_input, distributed_preprocessing \\ &Function.identity/1) def dispatch_many!(name, batch_input, distributed_preprocessing) when is_atom(name) do if pid = Process.whereis(name) do local_batched_run!(pid, name, batch_input, :batch) else distributed_batched_run!(name, batch_input, distributed_preprocessing, :batch) end end def dispatch_many!({:local, name}, batch_input, _distributed_preprocessing) when is_atom(name) do pid = Process.whereis(name) || exit({:noproc, {__MODULE__, :local_batched_run_batch, [name, batch_input]}}) local_batched_run!(pid, name, batch_input, :batch) end def dispatch_many!({:distributed, name}, batch_input, distributed_preprocessing) when is_atom(name) do distributed_batched_run!(name, batch_input, distributed_preprocessing, :batch) end @doc """ Safe variant of `dispatch/3` that does not exit on runtime failures. Returns `{:ok, result}` or `{:error, reason}`. """ def dispatch_safe(name, input, distributed_preprocessing \\ &Function.identity/1) do {:ok, dispatch(name, input, distributed_preprocessing)} catch :exit, reason -> {:error, reason} end @doc """ Safe variant of `dispatch_many/3` that does not exit on runtime failures. Returns `{:ok, result}` or `{:error, reason}`. """ def dispatch_many(name, batch_input, distributed_preprocessing \\ &Function.identity/1) do {:ok, dispatch_many!(name, batch_input, distributed_preprocessing)} catch :exit, reason -> {:error, reason} end defp local_batched_run!(pid, name, input, mode) do case local_batched_run(pid, name, input, mode) do {:ok, result} -> result {:DOWN, reason} -> exit({reason, {__MODULE__, :local_batched_run, [name, input]}}) end end defp local_batched_run(pid, name, input, input_mode) do %{ preprocessing: preprocessing, postprocessing: postprocessing, limit: limit, mode: serving_mode } = :persistent_term.get(persistent_key(name), nil) || raise( ArgumentError, "could not find BatchServing with name #{inspect(name)}. " <> "Make sure your BatchServing is running and/or started as part of your supervision tree" ) preprocessed = handle_preprocessing(preprocessing, input, input_mode) ref = :erlang.monitor(:process, pid, alias: :demonitor) # ref = Process.monitor(pid, alias: :demonitor) size_or_unknown = case preprocessed do %BatchServing.Batch{size: size} = batch -> if serving_mode == :hooks and batch.size > limit do batch |> run_batch_or_stream(limit) |> Enum.each(fn split_batch -> Process.send(pid, {__MODULE__, :dispatch, [ref], split_batch}, [:noconnect]) end) else Process.send(pid, {__MODULE__, :dispatch, [ref], batch}, [:noconnect]) end size stream -> if serving_mode == :hooks do raise ArgumentError, "streaming hooks do not support input streaming; map_inputs must produce a single value list" end spawn_link(fn -> # We also need to monitor the streaming process. To avoid leaking # messages in the parent inbox, we ask the serving to do it. Process.send(pid, {__MODULE__, :proxy_monitor, self(), ref}, [:noconnect]) monitor_ref = Process.monitor(pid) pending = Enum.reduce(stream, 0, fn %BatchServing.Batch{size: size} = batch, acc when size <= limit -> refs = [ref, self()] Process.send(pid, {__MODULE__, :dispatch, refs, batch}, [:noconnect]) acc + size other, _acc -> raise "mapped input produced an invalid batch of maximum size #{limit}, " <> "got: #{inspect(other)}" end) receive_size(monitor_ref, ref, pending) end) :unknown end case serving_mode do :execute -> case receive_execute(ref, size_or_unknown) do {:ok, value} -> {:ok, handle_postprocessing(postprocessing, value)} {:DOWN, reason} -> {:DOWN, reason} end _ -> stream = receive_stream("dispatch/2", ref, size_or_unknown) {:ok, handle_postprocessing(postprocessing, stream)} end end defp distributed_batched_run!(name, input, distributed_callback, mode) do distributed_batched_run_with_retries!(name, distributed_callback.(input), 3, mode) end defp distributed_batched_run_with_retries!(name, input, 0, _mode) do exit({:noproc, {__MODULE__, :distributed_batched_run, [name, input, [retries: 0]]}}) end defp distributed_batched_run_with_retries!(name, input, retries, mode) do case :pg.get_members(BatchServing.PG, __MODULE__) do [] -> exit({:noproc, {__MODULE__, :distributed_batched_run, [name, input, [retries: retries]]}}) entries -> pid = Enum.random(entries) ref = make_ref() args = [self(), ref, name, input, mode] {_, monitor_ref} = Node.spawn_monitor(node(pid), __MODULE__, :__distributed_batched_run__, args) receive do {^ref, :streaming} -> owner = self() Stream.resource( fn -> if self() != owner do raise "the stream returned from BatchServing.dispatch/2 must be consumed in the same process" end :ok end, fn :ok -> receive do {^ref, event} -> {[event], :ok} {:DOWN, ^monitor_ref, _, _, {^ref, :streaming}} -> {:halt, :ok} {:DOWN, ^monitor_ref, _, _, reason} -> exit({reason, {BatchServing, :streaming, []}}) end end, fn _ -> :ok end ) {:DOWN, ^monitor_ref, _, _, {^ref, result}} -> result {:DOWN, ^monitor_ref, _, _, :noproc} -> distributed_batched_run_with_retries!(name, input, retries - 1, mode) {:DOWN, ^monitor_ref, _, _, reason} -> exit_args = [name, input, [retries: retries]] exit({reason, {__MODULE__, :distributed_batched_run, exit_args}}) end end end @doc false def __distributed_batched_run__(client_pid, ref, name, input, mode) do pid = Process.whereis(name) || exit(:noproc) case local_batched_run(pid, name, input, mode) do {:ok, result} -> %{mode: mode, distributed_postprocessing: dist_post} = :persistent_term.get(persistent_key(name)) if mode == :execute do exit({ref, dist_post.(result)}) else send(client_pid, {ref, :streaming}) Enum.each(dist_post.(result), &send(client_pid, {ref, &1})) exit({ref, :streaming}) end {:DOWN, reason} -> exit(reason) end end ## Client message receiving defp receive_size(_monitor, _ref, 0), do: :ok defp receive_size(monitor_ref, ref, pending) do receive do {^ref, size} -> receive_size(monitor_ref, ref, pending - size) {:DOWN, ^monitor_ref, _, _, reason} -> exit(reason) end end defp receive_stream(fun, ref, size) when is_integer(size) or size == :unknown do owner = self() Stream.resource( fn -> if self() != owner do raise "the stream returned from BatchServing.#{fun} must be consumed in the same process" end 0 end, fn ^size -> {:halt, :done} index -> case receive_each(ref, size, index) do :done -> {:halt, :done} {:hook, {hook_start, hook_size, output, hook}} -> value = Enum.slice(output, hook_start, hook_size) {[{hook, value}], index} {:batch, {output_start, output_size, output}} -> value = Enum.slice(output, output_start, output_size) {[{:batch, value}], index + output_size} {:DOWN, reason} -> exit({reason, {BatchServing, :streaming, []}}) end end, fn _ -> :ok end ) end defp receive_execute(ref, size) when is_integer(size) or size == :unknown do receive_execute(ref, size, 0, []) end defp receive_execute(ref, size, index, acc) do case receive_each(ref, size, index) do :done -> {:ok, acc} {:batch, {output_start, output_size, output}} -> # If we have a single response, slice and return immediately. # Otherwise we collect their contents and build the concatenated result later. if acc == [] and output_size + index == size do {:ok, Enum.slice(output, output_start, output_size)} else receive_execute( ref, size, index + output_size, acc ++ Enum.slice(output, output_start, output_size) ) end {:DOWN, reason} -> {:DOWN, reason} end end defp receive_each(_ref, size, size) do :done end defp receive_each(ref, size, index) do receive do {^ref, {:hook, _} = reply} -> reply {^ref, {:batch, {_output_start, output_size, _output}} = reply} -> if output_size + index == size do Process.demonitor(ref, [:flush]) end reply # The serving itself never finishes with normal reason, # but the streaming process does to signal it is concluded # and its messages are proxied here. {:DOWN, ^ref, _, _, :normal} -> Process.demonitor(ref, [:flush]) :done {:DOWN, ^ref, _, _, reason} -> # We fake monitor messages, so still demonitor and flush. Process.demonitor(ref, [:flush]) {:DOWN, reason} end end ## Process callbacks require Logger @behaviour GenServer @single_stack_key {__MODULE__, :stack} @empty_stack {[], 0, :none} @empty_queue :queue.new() @timeout_message __MODULE__ @impl true def init({name, serving, partitions, batch_size, batch_timeout, task_supervisor}) do Process.flag(:trap_exit, true) partitions_opts = serving_partitions(serving, partitions) partitions_count = length(partitions_opts) {mode, partitions_opts, hooks_table} = serving_streaming(serving, partitions_opts) {:ok, module_state} = handle_init(serving.module, :process, serving.arg, partitions_opts) :persistent_term.put( persistent_key(name), %{ limit: batch_size, preprocessing: serving.map_inputs, postprocessing: serving.map_results, distributed_postprocessing: serving.distributed_postprocessing, mode: mode } ) :pg.join(BatchServing.PG, __MODULE__, List.duplicate(self(), partitions_count)) stack_init() # We keep batches in a stack. Once the stack is full # or it times out, we either execute or enqueue it. state = %{ module: serving.module, module_state: module_state, limit: batch_size, timeout: batch_timeout, in_queue: @empty_queue, out_queue: Enum.reduce(0..(partitions_count - 1), :queue.new(), &:queue.in/2), tasks: [], pending_batches: @empty_queue, task_supervisor: task_supervisor, hooks_table: hooks_table } {:ok, state} end defp serving_partitions(%BatchServing{runtime_options: runtime_options}, partitions) do List.duplicate(runtime_options, partitions) end defp serving_streaming(%BatchServing{streaming: nil}, partitions) do {:execute, partitions, nil} end defp serving_streaming(%BatchServing{streaming: %{hooks: []}}, partitions) do {:batches, partitions, nil} end defp serving_streaming(%BatchServing{streaming: %{hooks: hooks}}, partitions) do ets = :ets.new(__MODULE__, [:public, :set, read_concurrency: true]) partitions = Enum.with_index(partitions, fn runtime_options, index -> update_in(runtime_options[:hooks], fn acc -> Enum.reduce(hooks, acc || %{}, fn hook, acc -> Map.put(acc, hook, &server_hook(ets, index, hook, &1)) end) end) end) {:hooks, partitions, ets} end defp server_hook(ets, index, hook, result) do for {[ref | _pids], start, size} <- :ets.lookup_element(ets, index, 2) do send(ref, {ref, {:hook, {start, size, result, hook}}}) end end @impl true def handle_info({__MODULE__, :proxy_monitor, pid, ref}, state) do # Process.monitor(pid, tag: {:proxy, ref}) :erlang.monitor(:process, pid, tag: {:proxy, ref}) {:noreply, state} end def handle_info({__MODULE__, :dispatch, refs, %BatchServing.Batch{} = batch}, state) do %{limit: limit} = state count = stack_count() state = cond do # Single entry takes the whole batch. # Execute what we have (if any) and execute a new one. batch.size == limit -> state |> server_execute() |> server_stack(refs, batch, :skip_timer) |> server_execute() # We go over the limit, but if using hooks, we can't split. batch.size + count > limit and state.hooks_table != nil -> state |> server_execute() |> server_stack(refs, batch, :set_timer) # Split as necessary. true -> server_stack_and_execute_loop(state, batch, count, refs) end {:noreply, state} end def handle_info({@timeout_message, :timeout, ref}, %{out_queue: out_queue} = state) do case stack_timer() do # We have processing power, so execute it immediately. {^ref, _timer_ref} when out_queue != @empty_queue -> {:noreply, server_execute(state)} # Otherwise we will queue it but keep on increasing the batch. {^ref, _timer_ref} -> stack_update(fn {[_ | _] = stack, count, _timer} -> {stack, count, :done} end) {:noreply, update_in(state.in_queue, &:queue.in(:pending, &1))} # Otherwise this is an old timer message, just ignore it. _ -> {:noreply, state} end end def handle_info({ref, :done}, %{tasks: tasks} = state) do case Enum.split_with(tasks, &(elem(&1, 0).ref == ref)) do {[{_task, partition, _ref_sizes}], tasks} -> Process.demonitor(ref, [:flush]) noreply_task_done_and_continue(state, tasks, partition) _ -> {:noreply, state} end end def handle_info({{:proxy, ref}, _ref, type, info, reason}, state) do send(ref, {:DOWN, ref, type, info, reason}) {:noreply, state} end def handle_info({:DOWN, ref, :process, _process, reason}, %{tasks: tasks} = state) do case Enum.split_with(tasks, &(elem(&1, 0).ref == ref)) do {[{_task, partition, ref_sizes}], tasks} -> server_reply_down(reason, ref_sizes) noreply_task_done_and_continue(state, tasks, partition) _ -> {:noreply, state} end end def handle_info(msg, state) do Logger.warning("Unknown message in Serving: #{inspect(msg)}") {:noreply, state} end @impl true def handle_continue(:maybe_task, state) do {:noreply, server_maybe_task(state)} end @impl true def terminate(_reason, %{tasks: tasks, pending_batches: pending_batches}) do # Emulate the process is gone for entries in the queue. for {_batch, ref_sizes} <- :queue.to_list(pending_batches) do server_reply_down(:noproc, ref_sizes) end # As well as for entries in the stack. for {[ref | _pids], _batch} <- stack_entries() do send(ref, {:DOWN, ref, :process, self(), :noproc}) end # And wait until all current tasks are processed for {%Task{ref: ref}, _partition, ref_sizes} <- tasks do receive do {^ref, :done} -> Process.demonitor(ref, [:flush]) {:DOWN, ^ref, :process, _, reason} -> server_reply_down(reason, ref_sizes) end end :ok end # We don't spawn the task here because, if it crashes, # we want a checked-in version of the state that knows # the current task has finished. defp noreply_task_done_and_continue(%{out_queue: out_queue} = state, tasks, partition) do out_queue = :queue.in(partition, out_queue) {:noreply, %{state | tasks: tasks, out_queue: out_queue}, {:continue, :maybe_task}} end defp server_reply_down(reason, ref_sizes) do for {[ref | _refs], _start, _size} <- ref_sizes do send(ref, {:DOWN, ref, :process, self(), reason}) end end defp server_stack_and_execute_loop(state, batch, count, refs) do %{limit: limit} = state %{size: size} = batch cond do size + count < limit -> server_stack(state, refs, batch, :set_timer) size + count > limit -> {current, batch} = BatchServing.Batch.split(batch, limit - count) state |> server_stack(refs, current, :skip_timer) |> server_execute() |> server_stack_and_execute_loop(batch, 0, refs) true -> state |> server_stack(refs, batch, :skip_timer) |> server_execute() end end defp server_stack(%{limit: limit} = state, refs, batch, timer_mode) do stack_update(fn {stack, count, timer} when batch.size + count <= limit -> timer = if timer == :none and timer_mode == :set_timer do ref = make_ref() {ref, Process.send_after(self(), {@timeout_message, :timeout, ref}, state.timeout)} else timer end {[{refs, batch} | stack], count + batch.size, timer} end) state end defp server_execute(state) do if stack_count() == 0 do state else {batch_refs, timer} = stack_to_batch_refs() state = update_in(state.pending_batches, &:queue.in(batch_refs, &1)) state = if timer == :done do state else update_in(state.in_queue, &:queue.in(:pending, &1)) end server_maybe_task(state) end end defp server_maybe_task(state) do %{out_queue: out_queue, in_queue: in_queue, pending_batches: pending_batches} = state with {{:value, partition}, out_queue} <- :queue.out(out_queue), {{:value, :pending}, in_queue} <- :queue.out(in_queue) do {{batch, ref_sizes}, pending_batches} = case :queue.out(pending_batches) do {:empty, _pending_batches} -> # If there is no entry pending, then we have a timed-out in-construction batch. {batch_refs, :done} = stack_to_batch_refs() {batch_refs, pending_batches} {{:value, batch_refs}, queue} -> {batch_refs, queue} end %{module: module, module_state: module_state, hooks_table: hooks_table} = state {:execute, function, module_state} = handle_batch(module, batch, partition, module_state) wrapped_function = fn -> :telemetry.span([:batch_serving, :serving, :execute], %{module: module}, fn -> if hooks_table do :ets.insert(hooks_table, {partition, ref_sizes}) end output = function.() for {[ref | pids], start, size} <- ref_sizes do send(ref, {ref, {:batch, {start, size, output}}}) for pid <- pids do send(pid, {ref, size}) end end {:done, %{module: module}} end) end task = Task.Supervisor.async_nolink(state.task_supervisor, wrapped_function) tasks = [{task, partition, ref_sizes} | state.tasks] %{ state | module_state: module_state, tasks: tasks, out_queue: out_queue, in_queue: in_queue, pending_batches: pending_batches } else _ -> state end end ## Stack management # # The stack is stored in the process dictionary for performance. defp stack_init do Process.put(@single_stack_key, @empty_stack) :ok end defp stack_count do {_stack, count, _timer} = Process.get(@single_stack_key) count end defp stack_timer do {_stack, _count, timer} = Process.get(@single_stack_key) timer end defp stack_entries do {stack, _count, _timer} = Process.get(@single_stack_key) stack end defp stack_update(fun) do Process.put(@single_stack_key, fun.(Process.get(@single_stack_key))) :ok end defp stack_to_batch_refs do {[_ | _] = stack, count, timer} = Process.get(@single_stack_key) :ok = stack_init() with {ref, timer_ref} <- timer do Process.cancel_timer(timer_ref) receive do {@timeout_message, :timeout, ^ref} -> :ok after 0 -> :ok end end {ref_sizes, batches, _} = Enum.reduce(stack, {[], [], count}, fn {refs, batch}, {ref_sizes, batches, ending} -> size = batch.size {[{refs, ending - size, size} | ref_sizes], [batch | batches], ending - size} end) {{BatchServing.Batch.merge(batches), ref_sizes}, timer} end ## Shared helpers defp persistent_key(name) when is_atom(name) do {__MODULE__, name} end defp handle_init(module, type, arg, [_ | _] = partitions) do case module.init(type, arg, partitions) do {:ok, _} = pair -> pair other -> raise "#{inspect(module)}.init/3 must return {:ok, state}. Got: #{inspect(other)}" end end defp handle_batch(module, batch, partition, state) do case module.handle_batch(batch, partition, state) do {:execute, function, _} = pair when is_function(function, 0) -> pair other -> raise "#{inspect(module)}.handle_batch/3 must return {:execute, function, state}, " <> "where function is a function that receives no arguments and returns output. " <> "Got: #{inspect(other)}" end end defp handle_executed(_module, result), do: result defp handle_preprocessing(preprocessing, input, :single) do handle_preprocessing(preprocessing, [input], :batch) end defp handle_preprocessing(nil, batch_input, :batch) do mapped_to_batch_or_stream(batch_input) end defp handle_preprocessing(preprocessing, input, _mode) do meta = %{input: input} :telemetry.span([:batch_serving, :serving, :preprocessing], meta, fn -> mapped = preprocessing.(input) batch_or_stream = mapped_to_batch_or_stream(mapped) || raise_bad_map_inputs!(preprocessing, mapped) {batch_or_stream, meta} end) end defp raise_bad_map_inputs!(preprocessing, result) do raise "map_inputs function #{inspect(preprocessing)} must return a list of values, " <> "or a stream of values. Got: #{inspect(result)}" end defp mapped_to_batch_or_stream(values) when is_list(values) do mapped_list_to_batch!(values) end defp mapped_to_batch_or_stream(stream) do if Enumerable.impl_for(stream) do Stream.map(stream, &mapped_stream_entry_to_batch/1) end end defp mapped_list_to_batch!(values) when is_list(values) do if values == [], do: raise(ArgumentError, "cannot inline with empty value list") BatchServing.Batch.values(values) end defp mapped_stream_entry_to_batch(value) do BatchServing.Batch.values([value]) end defp handle_postprocessing(nil, result), do: result defp handle_postprocessing(postprocessing, result) do :telemetry.span([:batch_serving, :serving, :postprocessing], %{}, fn -> {postprocessing.(result), %{}} end) end end defmodule BatchServing.Default do @moduledoc false @behaviour BatchServing @impl true def init(_type, fun, partitions) do batch_funs = Enum.with_index(partitions, fn runtime_options, index -> _ = runtime_options value = fn batch -> fun.(batch.values) end {index, value} end) {:ok, Map.new(batch_funs)} end @impl true def handle_batch(batch, partition, batch_funs) do batch_fun = Map.fetch!(batch_funs, partition) {:execute, fn -> batch_fun.(batch) end, batch_funs} end end