Add runtime_raise inside defn - #1822
Conversation
Keep callback failures structured on the BEAM so they halt execution without crashing the linked runner process.
Allow defn computations to halt from tensor predicates while preserving the checked value on the non-raising path.
| defmacro raise_if(value, predicate, exception_or_message) do | ||
| quote do | ||
| value = unquote(value) | ||
|
|
||
| Nx.Defn.Kernel.if(unquote(predicate), | ||
| do: | ||
| Nx.Defn.Kernel.io_call(value, fn _ -> | ||
| Elixir.Kernel.raise(unquote(exception_or_message)) | ||
| end), | ||
| else: value | ||
| ) | ||
| end | ||
| end |
There was a problem hiding this comment.
I think we should try to make raise itself become a special node that works in runtime when called inside defn. I'm just not sure how to make raise work inside case. raise_if is a nice out if we can't make it happen, though.
There was a problem hiding this comment.
Went with throw-during-trace, catch in cond/while, then the existing io_call raise shape. No new Expr op: those are tensors and raise has no type/shape. case stays trace-time (it's Elixir case). raise_if is now if + raise.
| EXLA.Defn.Runner.start_link(lock, fn -> | ||
| EXLA.Executable.run(executable, [Enum.reverse(buffers)], run_options) | ||
| try do | ||
| EXLA.Executable.run(executable, [Enum.reverse(buffers)], run_options) |
There was a problem hiding this comment.
I think we're missing a change in the c++ code to not raise when unwrapping the result. This way if the callback returns {:error, ...} we can just surface that tuple without raising
There was a problem hiding this comment.
run no longer unwrap-throws. Failed status comes back as {:error, message} from the NIF. Executable.run still raises that so the public contract is unchanged. Callback exceptions themselves come from the Outfeed DOWN.
|
|
||
| callback_error = | ||
| receive do | ||
| {:exla_callback_error, ^error_ref, kind, reason, stacktrace} -> |
There was a problem hiding this comment.
It would be nice to not have to deal with additional messages. One option is to wrap the raised exception in EXLA.Defn.CallbackError{kind: kind, reason: reason, stacktrace: stacktrace} and then in here, you can check the DOWN reason (the fifth element of the tuple) and unpack it accordingly. Then we don't need the error ref, we don't need to pass self, etc.
There was a problem hiding this comment.
Done. Outfeed exits with {:shutdown, %EXLA.Defn.CallbackError{...}} and maybe_outfeed unpacks the DOWN reason. {:shutdown, _} so Task.Supervisor doesn't log it. No error ref, extra messages, or passing self.
Outfeed exits with CallbackError instead of sending a side message, and the NIF returns {:error, message} instead of throwing on a failed Run.
Trace-time raise still applies at the top level and in case. raise_if is now sugar over if plus raise.
| defmacro raise(exception, arguments) do | ||
| quote do | ||
| Elixir.Kernel.raise(unquote(exception), unquote(arguments)) | ||
| Nx.Defn.Expr.trace_raise(unquote(exception), unquote(arguments)) | ||
| end |
There was a problem hiding this comment.
I think if we denormalize this node to regular Elixir.Kernel.raise when inside :case, we can implement Nx.Defn.Expr.raise such that it is always runtime only.
There was a problem hiding this comment.
If this works, we can then remove raise-if and the throw-catch trick
There was a problem hiding this comment.
Case already ends up as Kernel.raise: only the matching clause runs, the throw escapes, and the compiler converts it. Rewriting in normalize({:case}) is the same outcome.
Always-runtime Expr.raise doesn't cover if Nx.rank(t) != 2, do: raise then {r, c} = shape. Rank is only known while tracing, and a discarded runtime node lets tracing continue into the match (that's the LinAlg CI failure). 4392b55 Kernel.raises when the remaining if/cond clause is already known true.
I couldn't drop the throw-catch: if raise only returns a node, raise; value falls through. Same question for raise_if as the pipeable wrapper. Do you have something in mind for those?
There was a problem hiding this comment.
@polvalente ah, the issue is that we already allow raise while building the graph, and that actually raises, so we cannot convert the same raise to raise at runtime. We will likely need runtime_raise or io_raise or something instead. :( Thoughts?
There was a problem hiding this comment.
Went with runtime_raise. raise is back to Kernel.raise while building the graph. raise_if is if plus runtime_raise.
There was a problem hiding this comment.
Let's remove raise_if. The original reason for adding it was for disambiguation of plain raise
A discarded compile-time raise was turning into an io_call, so tracing continued into later pattern matches and LinAlg doctests got MatchError.
raise already meant "while building the graph", so it cannot also be a runtime host callback. runtime_raise is that callback; raise_if is if plus runtime_raise.
| defn custom_runtime_raise(value, predicate) do | ||
| if predicate do | ||
| runtime_raise RuntimeRaiseError, | ||
| message: "custom runtime check failed", | ||
| value: :preserved | ||
| else | ||
| value | ||
| end | ||
| end | ||
|
|
There was a problem hiding this comment.
we might want a test where we have a custom call raise and catch inside itself to ensure that we only bubble up uncaught raises
| while {x, i = 0, n}, i < 10 do | ||
| i = | ||
| if i == n do | ||
| runtime_raise "Halting on selected iteration" |
There was a problem hiding this comment.
Maybe have one of these include the iteration as an argument? Not sure if the tensor would survive allocation, though. Seems like an interesting experiment.
There was a problem hiding this comment.
the tensor does survive when passing through io_call
| case Nx.Defn.Expr.catch_runtime_raise(fn -> | ||
| fun | ||
| |> apply(args) | ||
| |> Nx.Defn.Composite.traverse(&Nx.Defn.Expr.tensor/1) | ||
| end) do | ||
| {:value, result} -> | ||
| result | ||
|
|
||
| {:raise, spec} -> | ||
| Nx.Defn.Expr.apply_runtime_raise(spec) | ||
| end | ||
| after | ||
| if previous_backend do | ||
| Process.put(Nx.Shared.backend_pdict_key(), previous_backend) |
There was a problem hiding this comment.
Do we still need this throw-catch mechanism given that runtime_raise is a new Expr node?
I believe we can have the compiler introduce the raise as a runtime thing when it is reached during execution.
There was a problem hiding this comment.
after our talk, i'll have a ghost tensor to flag so that it is included on the result but removed before the final return - inspired by what is done on callback pid on exla
There was a problem hiding this comment.
I think this is due to how tracing is implemented. runtime_raise should be functionally equivalent to an io_call that raises, but it has a special node to not require the reassignment and passing a tensor as argument
It was only there to disambiguate from raise. Also cover a rescued io_call callback and raising with the runtime loop index.
The branch result is passed through the callback, like other io_call values, so the raise stays in the graph without a tracing throw.
| Outfeed.start_child( | ||
| executable, | ||
| outfeed, | ||
| Process.group_leader(), | ||
| infeeds | ||
| ) |
There was a problem hiding this comment.
We can revert this change!
| try do | ||
| EXLA.Executable.run(executable, [Enum.reverse(buffers)], run_options) | ||
| after | ||
| send(outfeed_pid, :stop) |
There was a problem hiding this comment.
Why is this necessary? Shouldn't we keep it in the outfeed as before?
| {{:shutdown, %EXLA.Defn.CallbackError{} = error}, _runner_result} -> | ||
| :erlang.raise(error.kind, error.reason, error.stacktrace) | ||
|
|
||
| {_down_reason, {:error, kind, reason, stacktrace}} -> | ||
| :erlang.raise(kind, reason, stacktrace) |
There was a problem hiding this comment.
I don't understand why we have both the CallbackError and the {:error, kind, reason, stacktrace}. Could they be unified into one?
| decompose_output(data_and_device_id, output_typespecs, client, mesh) | ||
| case run(client, ref, device_id, inputs, options) do | ||
| {:error, message} -> | ||
| raise RuntimeError, message: IO.iodata_to_binary(message) |
There was a problem hiding this comment.
Let's return {:error, message} to the caller and let the caller choose to raise.
| results -> | ||
| for data_and_device_id <- results do | ||
| decompose_output(data_and_device_id, output_typespecs, client, mesh) | ||
| end |
There was a problem hiding this comment.
Let's make this proper tagged tuples:
| results -> | |
| for data_and_device_id <- results do | |
| decompose_output(data_and_device_id, output_typespecs, client, mesh) | |
| end | |
| {:ok, results} -> | |
| {:ok, for data_and_device_id <- results do | |
| decompose_output(data_and_device_id, output_typespecs, client, mesh) | |
| end} |
|
I have dropped some comments but it seems the implementation of Nx.Defn.Expr is still a bit too complex. Could runtime_raise be equivalent to: or similar? On the other hand, we may have issues with tensors and inspect and what not, so an alternative is: where we require raise to be a compile-time string. |
Dropped the Expr tracing stuff. runtime_raise is now io_call(Nx.tensor(0), fn _ -> Kernel.raise(msg) end). The message is a compile-time string to avoid dealing with tensors/inspect, is this ok? |
Nx.Defn.Kernel.runtime_raise inside if/cond/while runs at runtime when that branch is taken. raise stays a trace-time check for shapes, ranks, and case.
Failed EXLA callbacks used to crash the Runner GenServer. They now exit the Outfeed with CallbackError and raise in the original caller, so they can be rescued or tested with assert_raise.