Should Rationals be explicitly converted?

The question is basically, which is “better” in terms of intermediate type-stability and/or performance:

function half_promote(x :: T) :: T where T <: AbstractFloat
    half = 1//2
    return half * x
end

or

function half_convert(x :: T) :: T where T <: AbstractFloat
    half = oftype(x, 1//2)
    return half * x
end

?

For type-stability I look at the @code_warntype (for which the man-page is good but doesn’t seem to answer my question) below, but I can’t seem to extract a meaningful difference other than the explicit conversion in the latter.

julia> @code_warntype half_promote(2.0)
MethodInstance for half_promote(::Float64)
  from half_promote(x::T) where T<:AbstractFloat @ Main REPL[6]:1
Static Parameters
  T = Float64
Arguments
  #self#::Core.Const(Main.half_promote)
  x::Float64
Locals
  half::Rational{Int64}
  @_4::Float64
Body::Float64
1 ─ %1  = $(Expr(:static_parameter, 1))::Core.Const(Float64)
│         (half = 1 // 2)
│   %3  = half::Core.Const(1//2)
│   %4  = (%3 * x)::Float64
│         (@_4 = %4)
│   %6  = @_4::Float64
│   %7  = (%6 isa %1)::Core.Const(true)
└──       goto #3 if not %7
2 ─       goto #4
3 ─       Core.Const(:(@_4))
│         Core.Const(:(Base.convert(%1, %10)))
└──       Core.Const(:(@_4 = Core.typeassert(%11, %1)))
4 ┄ %13 = @_4::Float64
└──       return %13

and

julia> @code_warntype half_convert(2.0)
MethodInstance for half_convert(::Float64)
  from half_convert(x::T) where T<:AbstractFloat @ Main REPL[7]:1
Static Parameters
  T = Float64
Arguments
  #self#::Core.Const(Main.half_convert)
  x::Float64
Locals
  half::Float64
  @_4::Float64
Body::Float64
1 ─ %1  = $(Expr(:static_parameter, 1))::Core.Const(Float64)
│   %2  = Main.oftype::Core.Const(oftype)
│   %3  = (1 // 2)::Core.Const(1//2)
│         (half = (%2)(x, %3))
│   %5  = half::Core.Const(0.5)
│   %6  = (%5 * x)::Float64
│         (@_4 = %6)
│   %8  = @_4::Float64
│   %9  = (%8 isa %1)::Core.Const(true)
└──       goto #3 if not %9
2 ─       goto #4
3 ─       Core.Const(:(@_4))
│         Core.Const(:(Base.convert(%1, %12)))
└──       Core.Const(:(@_4 = Core.typeassert(%13, %1)))
4 ┄ %15 = @_4::Float64
└──       return %15

Thankfully, the LLVM lowering is a little more concise and provides nearly identical results with the only difference being in the comments.

julia> @code_llvm half_promote(2.0)
; Function Signature: half_promote(Float64)
;  @ REPL[6]:1 within `half_promote`
define double @julia_half_promote_10502(double %"x::Float64") #0 {
top:
;  @ REPL[6]:3 within `half_promote`
; ┌ @ promotion.jl:430 within `*` @ float.jl:493
   %0 = fmul double %"x::Float64", 5.000000e-01
; └
  ret double %0
}

and

julia> @code_llvm half_convert(2.0)
; Function Signature: half_convert(Float64)
;  @ REPL[7]:1 within `half_convert`
define double @julia_half_convert_10519(double %"x::Float64") #0 {
top:
;  @ REPL[7]:3 within `half_convert`
; ┌ @ float.jl:493 within `*`
   %0 = fmul double %"x::Float64", 5.000000e-01
; └
  ret double %0
}

So does the identical IR actually mean there is literally no difference between these functions?

There should be no difference in terms of type stability or in terms of performance between the two snippets. Don’t see anything special about Rational here, either.

Some suggestions:

  • Don’t do convert or oftype unnecessarily, let promotion (promote) take care of things.

  • Don’t use method return type annotations.

  • Don’t use unnecessary method static parameters. In your example, instead of (x :: T) :: T where T <: AbstractFloat, you could’ve just written (x::AbstractFloat)

1 Like

Could you elaborate? On any of your points?

I use Rational because I want to be able to make sure that my numbers stay the same type they start with because that’s the type that’s going to be returned anyway, so any promotion is effectively wasted. e.g. I don’t want to use 0.5 because if x is Float32, the result would first be promoted to a Float64 before being “demoted” to Float32 as instructed by the method type annotation. The terminal behavior doesn’t result in type-instability sure, but again, there would otherwise be needless promotion and demotion.

This is such a small toy example it’s hard to say what’s “best”. But generally, the simplest, most obvious computation is the right one. In this case, that’s almost surely x/2.

It’s really hard to extrapolate out to what your real world use-case might be.

3 Likes

I wouldn’t convert rationals in general, unless you know what you’re doing and want more performance. It’s easier to see with half → third:

function third_promote(x :: T) :: T where T <: AbstractFloat
    third = 1//3
    return third * x
end
function third_convert(x :: T) :: T where T <: AbstractFloat
    third = oftype(x, 1//3)
    return third * x
end
julia> Float32(1//3) # less accurate, even less with bfloat or FP8 or FP4
0.33333334f0

julia> Float64(1//3)
0.3333333333333333

If you convert eagerly you’re locked into the lower accuracy, and higher performance. Here it doesn’t matter since there’s nothing in between, but if you e.g. do third * some_other_var_also_rational, then you keep in the perfectly accurate rational slower domain for longer.

0.5 can promote yes (and it will never demote e.g. not from BigFloat, I checked), but that needs not be a bad thing. Yes 0.5f0 is as accurate on its own (even using it, demoting temporary may be ok, if you know what you’re doing), but if I’m not incorrect, it can help accuracy promoting temporary. On CPUs I believe Float64 is as fast, that would not be the case on GPUs…

Yes, when the denominator is a power of 2 integer, you get what you want, converted to a multiply, not the slower division (@code_native did NOT confirm that, since I believe in global scope). Note, it doesn’t happen with e.g.x/3.

1 Like

Bad news here, you can only do this much. * for Float16, Float32, Float64 forwards to the intrinsic Base.mul_float, and that starts deviating from your specified type down to the hardware.

julia> @code_llvm half_promote(Float16(3.5))
; Function Signature: half_promote(Float16)
;  @ REPL[5]:1 within `half_promote`
; Function Attrs: uwtable
define half @julia_half_promote_4499(half %"x::Float16") #0 {
top:
;  @ REPL[5]:3 within `half_promote`
; ┌ @ promotion.jl:430 within `*` @ float.jl:493
   %0 = fpext half %"x::Float16" to float
   %1 = fmul float %0, 5.000000e-01
   %2 = fptrunc float %1 to half
; └
  ret half %2
}

julia> @code_llvm half_convert(Float16(3.5))
; Function Signature: half_convert(Float16)
;  @ REPL[14]:1 within `half_convert`
; Function Attrs: uwtable
define half @julia_half_convert_4503(half %"x::Float16") #0 {
top:
;  @ REPL[14]:3 within `half_convert`
; ┌ @ float.jl:493 within `*`
   %0 = fpext half %"x::Float16" to float
   %1 = fmul float %0, 5.000000e-01
   %2 = fptrunc float %1 to half
; └
  ret half %2
}

So whether you convert strictly to the input type or give promotion a chance to do it, you end up converting Float16 (half) to Float32 (float) for the operation, then truncating back to Float16. If you’re not too bothered by the compiler doing as it pleases, then I’d prefer the semantics of explicit conversion; there’s no guarantee that promotion will favor an arbitrary AbstractFloat over Rational{Int}.

An intermediate operation in a higher precision is a widespread technique to reduce approximation errors, if that’s of interest.

For Float64 input there is no difference. Any conversion in the promotion of *, and oftype is inlined by the compiler, and results in the same code being generated, and none of these operations are actually performed at runtime. I think this will be so for all the built in floats. So, no performance difference. And no type stability issues.

The problem with generic programming is what happens with floats which are not yet defined. E.g. if someone decides to create a float which behaves differently. There is no guarantee that * will promote to the highest precision of the arguments for not yet existing floats.

And, also, what happens in more complicated examples? One thing is to return the same type as the input, but there is also a question of at what precision should intermediate calculations be performed. This depends on your application. You might want to do all calculations in the input precision, or you might want to do them in the highest native precision (typically Float64 or Float32), and convert to the input precision upon return (which is what your declaration function (...)::T does).

In general, arithmetic seldom leads to type instability.