Skip to content

Add runtime_raise inside defn - #1822

Open
Chapaman wants to merge 11 commits into
elixir-nx:mainfrom
Chapaman:structured-exla-callback-errors
Open

Add runtime_raise inside defn#1822
Chapaman wants to merge 11 commits into
elixir-nx:mainfrom
Chapaman:structured-exla-callback-errors

Conversation

@Chapaman

@Chapaman Chapaman commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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.

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.
@Chapaman Chapaman changed the title Raise EXLA callback errors in the caller Add runtime raise_if to Nx.Defn.Kernel Aug 20, 2026
@Chapaman
Chapaman marked this pull request as ready for review August 20, 2026 01:18
Comment thread nx/lib/nx/defn/kernel.ex Outdated
Comment on lines +1472 to +1484
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread exla/lib/exla/defn.ex Outdated
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread exla/lib/exla/defn.ex Outdated

callback_error =
receive do
{:exla_callback_error, ^error_ref, kind, reason, stacktrace} ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread nx/lib/nx/defn/kernel.ex
Comment on lines 1606 to 1609
defmacro raise(exception, arguments) do
quote do
Elixir.Kernel.raise(unquote(exception), unquote(arguments))
Nx.Defn.Expr.trace_raise(unquote(exception), unquote(arguments))
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

https://github.com/Chapaman/nx/blob/0fe88be090643087b1ce03a836b8289bc4900ba7/nx/lib/nx/defn/compiler.ex#L485

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this works, we can then remove raise-if and the throw-catch trick

@Chapaman Chapaman Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like runtime_raise!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with runtime_raise. raise is back to Kernel.raise while building the graph. raise_if is if plus runtime_raise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove raise_if. The original reason for adding it was for disambiguation of plain raise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

A discarded compile-time raise was turning into an io_call, so tracing continued into later pattern matches and LinAlg doctests got MatchError.
@Chapaman Chapaman changed the title Add runtime raise_if to Nx.Defn.Kernel Make raise work at runtime inside defn Aug 25, 2026
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.
@Chapaman Chapaman changed the title Make raise work at runtime inside defn Add runtime_raise inside defn Aug 26, 2026
Comment on lines +407 to +416
defn custom_runtime_raise(value, predicate) do
if predicate do
runtime_raise RuntimeRaiseError,
message: "custom runtime check failed",
value: :preserved
else
value
end
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread exla/test/exla/defn/api_test.exs Outdated
while {x, i = 0, n}, i < 10 do
i =
if i == n do
runtime_raise "Halting on selected iteration"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the tensor does survive when passing through io_call

Comment thread nx/lib/nx/defn/compiler.ex Outdated
Comment on lines 189 to 202
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Chapaman Chapaman Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread exla/lib/exla/defn.ex Outdated
Comment on lines +251 to +256
Outfeed.start_child(
executable,
outfeed,
Process.group_leader(),
infeeds
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can revert this change!

Comment thread exla/lib/exla/defn.ex Outdated
try do
EXLA.Executable.run(executable, [Enum.reverse(buffers)], run_options)
after
send(outfeed_pid, :stop)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary? Shouldn't we keep it in the outfeed as before?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

Comment thread exla/lib/exla/defn.ex Outdated
Comment on lines +278 to +282
{{: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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why we have both the CallbackError and the {:error, kind, reason, stacktrace}. Could they be unified into one?

Comment thread exla/lib/exla/executable.ex Outdated
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's return {:error, message} to the caller and let the caller choose to raise.

Comment thread exla/lib/exla/executable.ex Outdated
Comment on lines +54 to +57
results ->
for data_and_device_id <- results do
decompose_output(data_and_device_id, output_typespecs, client, mesh)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this proper tagged tuples:

Suggested change
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}

@josevalim

Copy link
Copy Markdown
Contributor

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:

defmacro runtime_raise(arg) do
  quote do
    io_call(fn -> Kernel.raise(arg) end)
  end
end

or similar?

On the other hand, we may have issues with tensors and inspect and what not, so an alternative is:

defmacro runtime_raise(arg) when is_binary(arg) do
  quote do
    io_call(fn -> Kernel.raise(arg) end)
  end
end

where we require raise to be a compile-time string.

@Chapaman

Copy link
Copy Markdown
Contributor Author

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:

defmacro runtime_raise(arg) do
  quote do
    io_call(fn -> Kernel.raise(arg) end)
  end
end

or similar?

On the other hand, we may have issues with tensors and inspect and what not, so an alternative is:

defmacro runtime_raise(arg) when is_binary(arg) do
  quote do
    io_call(fn -> Kernel.raise(arg) end)
  end
end

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants