Why does `throw` break type inference?

I’m researching SSA-IR, and I found that most types of Julia functions are determined by compile-time, as they’re converted to invoke type from call type with a MethodInstance in code_typed. But there are still some call left there. Some of them are Core.Builtin, so I guess it’s unnecessary to “optimize” them, as they’re not even generic functions. And others come with some throw statements. The following code demonstrates it:

julia> function g()
           println(string(1, "2"))
           throw(string(2, "3"))
           println(string(3, "4"))
       end
g (generic function with 1 method)

julia> @code_typed g()
CodeInfo(
1 ─ %1 = Main.string(1, "2")::Any
│        Main.println(%1)::Any
│   %3 = Main.string(2, "3")::Any
│        Main.throw(%3)::Union{}
└──      unreachable
) => Union{}

julia> function gg()
                  println(string(1, "2"))
                  println(string(3, "4"))
              end
gg (generic function with 1 method)

julia> @code_typed gg()
CodeInfo(
1 ─ %1 = invoke Base.print_to_string(1::Int64, "2"::Vararg{Any})::String
│        invoke Main.println(%1::String)::Any
│   %3 = invoke Base.print_to_string(3::Int64, "4"::Vararg{Any})::String
│   %4 = invoke Main.println(%3::String)::Nothing
└──      return %4
) => Nothing

julia> @code_typed optimize=false g()
CodeInfo(
1 ─ %1 = Main.string(1, "2")::Any
│        Main.println(%1)::Any
│   %3 = Main.string(2, "3")::Any
│        Main.throw(%3)::Union{}
│        Core.Const(:(Main.string(3, "4")))::Union{}
│        Core.Const(:(Main.println(%5)))::Union{}
└──      Core.Const(:(return %6))::Union{}
) => Union{}

julia> @code_typed optimize=false gg()
CodeInfo(
1 ─ %1 = Main.string(1, "2")::String
│        Main.println(%1)::Any
│   %3 = Main.string(3, "4")::String
│   %4 = Main.println(%3)::Core.Const(nothing)
└──      return %4
) => Nothing

I see no reason why the first string call in the g is not specialized like what happened in the gg call. Maybe Julia considers it “unnecessary” as well since there’s an ensured throw?

This is not exactly the case anymore in julia v1.8:

julia> @code_typed g()
CodeInfo(
1 ─ %1 = invoke Base.print_to_string(1::Int64, "2"::Vararg{Any})::String
│        Main.println(%1)::Any
│   %3 = Main.string(2, "3")::Any
│        Main.throw(%3)::Union{}
└──      unreachable
) => Union{}

But in general, I think that any code path systematically leading to an exception is generally not fully optimized (for example the second string), because the runtime cost of throwing an exception is very high anyway. And avoiding useless optimizations allows the compiler to run fast, which is crucial to reduce latency.

1 Like

I think PR #35982 might be of interest to you. You can also have a look at the LazyString mechanism introduced in v1.8 in PR 33711, which is specifically made to make the compiler not to eagerly optimize the string computation in a throw block.