Why don't operations on strings constant-fold?

Why doesn’t bar constant-fold, although foo does?

julia> using BenchmarkTools

julia> foo(::Type{T}) where T = parse(T, '3')
       bar(::Type{T}) where T = parse(T, "3");

julia> @btime foo(Int)
       @btime bar(Int);
  1.500 ns (0 allocations: 0 bytes)
  16.934 ns (0 allocations: 0 bytes)

julia> @code_llvm foo(Int)
       @code_llvm bar(Int)

Part of me wants to say it shouldn’t, because !isbits("abc"). However, part of me also wants to say it should, because "abc" ≡ "abc".

String isn’t constant propagated currently. You can use Symbol instead here to constant propagate string-like data.

Unfortunately parse doesn’t have methods which accept a symbol.

It appears we can do this though:

julia> Base.@assume_effects foldable bar(::Type{T}) where T = parse(T, "3")
bar (generic function with 1 method)

julia> @btime bar(Int)
  1.000 ns (0 allocations: 0 bytes)
3

This works for this function, but when embedding string operations into impure functions it might not be so easy.

I presume the inability to const-prop strings isn’t a fundamental limitation, but is instead just an implementation detail of the compiler as currently implemented?

Constant propagation is an implementation detail of the compiler. To encourage the compiler to perform constant propagation on a function, regardless of its purity, you can use the syntax Base.@constprop :aggressive f(...) = . The Base.@assume_effects annotation does a slightly different thing (constant-folding, a.k.a. compiler-time evaluation) and should only be used on impure functions.

1 Like

I had tried Base.@constprop but it didn’t help:

julia> Base.@constprop :aggressive bar(::Type{T}) where T = parse(T, "3")
bar (generic function with 1 method)

julia> @btime bar(Int)
  16.884 ns (0 allocations: 0 bytes)
3

You mean foldable should only be used on pure functions, yes?

Constant prop is a different thing from constant folding (from the compiler optimization point of view) so that’s expected. If you really need to optimize away a call to a complex function, assume_effects based constand folding is the last resort for it.

But yes, it should only be used for “pure” function. Read carefully the documentation of assume_effects for the details.

But also, it would be good to teach the compiler that equal strings are egal and allow it to reason accordingly.

4 Likes

I seem to run into this problem frequently (and should probably learn more about the recent improved effect analysis) when writing higher precision algorithms. But it would be really convenient if a structure like.

function foo(x::T) where T
    P = ("1.23", "1.24", "1.2345")
    return evalpoly(x, parse.(T, P))
end
# or storing big floats as constants
const P2 = (big"1.23", big"1.24", big"1.2345")
function foo2(x::T) where T
    return evalpoly(x, T.(P2))
end

could just work. A recent similar discussion was at here and I posted a related question here.

julia> using DoubleFloats, Quadmath

julia> @btime foo($Double64(1.2))
  1.046 μs (16 allocations: 673 bytes)
4.49568

julia> @btime foo($Float128(1.2))
  295.539 ns (1 allocation: 32 bytes)
4.49567999999999981335818688421568617e+00

julia> @btime foo2($Double64(1.2))
  373.980 ns (7 allocations: 344 bytes)
4.49568

julia> @btime foo2($Float128(1.2))
  395.318 ns (13 allocations: 608 bytes)
4.49567999999999981335818688421568617e+00

julia> @btime foo($Float64(1.2))
  133.277 ns (1 allocation: 16 bytes)
4.49568

julia> @btime foo2($Float64(1.2))
  93.990 ns (1 allocation: 16 bytes)
4.49568

Generally, with explicit coefficients like this the algorithm isn’t arbitrary precision so the precision of BigFloats can be fixed. But ideally, we would be able to write algorithms for fixed higher precisions (Float128, Float256, Double64) that users could also potentially use any external package and not experience all these allocations.

It looks like though the external packages could also handle this conversion better. I must admit most of this issue is my general aversion to specifically loading these packages. Or perhaps a general desire for a julia native Float128 or Int256 type. :slight_smile:

Considering that it’s impractical to avoid BigInt and BigFloat, and it’s impossible (?) to make them constant-fold, probably the better approach for you will be to use a @generated function. See definition and performance of foo6, which matches the “hand-crafted” foo5:

julia> using DoubleFloats, Quadmath, BenchmarkTools
       Ts = (Float64, Double64, Float128);

julia> function foo(x::T) where T
           P = ("1.23", "1.24", "1.2345")
           return evalpoly(x, parse.(T, P))
       end
       const P2 = (big"1.23", big"1.24", big"1.2345")
       function foo2(x::T) where T
           return evalpoly(x, T.(P2))
       end
       Base.@assume_effects foldable foo3(x::T) where T = let P = ("1.23", "1.24", "1.2345"); evalpoly(x, parse.(T, P)) end
       Base.@assume_effects foldable foo4(x::T) where T = evalpoly(x, T.(P2))
       foreach(Ts) do T; eval(:(let P = map($T, (big"1.23", big"1.24", big"1.2345")); global foo5(x::$T) = evalpoly(x, P) end)) end
       @generated foo6(x::T) where T = let P = map(T, (big"1.23", big"1.24", big"1.2345")); :(evalpoly(x, $P)) end

       foos = (foo, foo2, foo3, foo4, foo5, foo6);

julia> foreach(foos) do f; println(f, ":"); foreach(Ts) do T; eval(:( @btime $f($T($(Expr(:$, 1.2)))) )) end end
foo:
  1.310 μs (0 allocations: 0 bytes)
  1.590 μs (15 allocations: 641 bytes)
  380.788 ns (0 allocations: 0 bytes)
foo2:
  107.309 ns (2 allocations: 80 bytes)
  606.250 ns (8 allocations: 392 bytes)
  575.824 ns (14 allocations: 656 bytes)
foo3:
  1.310 μs (0 allocations: 0 bytes)
  1.580 μs (15 allocations: 641 bytes)
  382.266 ns (0 allocations: 0 bytes)
foo4:
  107.459 ns (2 allocations: 80 bytes)
  609.659 ns (8 allocations: 392 bytes)
  577.473 ns (14 allocations: 656 bytes)
foo5:
  2.000 ns (0 allocations: 0 bytes)
  17.000 ns (0 allocations: 0 bytes)
  79.545 ns (0 allocations: 0 bytes)
foo6:
  2.000 ns (0 allocations: 0 bytes)
  17.034 ns (0 allocations: 0 bytes)
  79.442 ns (0 allocations: 0 bytes)

julia> all(Ts) do T; all(broadcast((f,x)->f(T(x)), foos, 1.2)) do x; x≡first(foos)(T(1.2)) end end # equality check
true
1 Like

fyi, @assume_effects :foldable for all the ccalls by oscardssmith · Pull Request #56 · JuliaMath/Quadmath.jl · GitHub should make Float128 operations constant fold pretty well.

3 Likes