Most elegant way to create a vector of results if the result is an unknown type?

Hi all, I am developing some simulation code that can be used either to do a quick calculation or a more complete and slower algorithm. I implemented two functions that either produce a “simple” or “complex” structure; for the sake of simplicity, let’s assume that the calculations are very trivial:

abstract type Results{T <: Number} end

struct SimpleResults{T} <: Results{T}
    val1::T
    val2::T
end

simple(input) = SimpleResults(input, input * 2)

struct ComplexResults{T} <: Results{T}
    val1::T
    val2::T
    val3::T
end

complex(input) = ComplexResults(input, input * 2, input * 3)

Both return types are derived from the abstract type Results.

In a typical case, these calculations should be performed many times in a row, producing a long vector of results. For these, I implemented a ListOfResults type that basically wraps a AbstractVector{<: Results} and keeps some ancillary information too (in info):

struct ListOfResults{R <: Results, V <: AbstractVector{R}}
    vec::V
    info::String
end

I am now a bit puzzled in thinking how to create a proper API for the function that creates a ListOfResults object. I would like to let the user decide whether simple or complex should be called and which vector-like use for ListOfResults.vec (Vector, CuArray, …).

Since the exact eltype of vec is decided only after a call to simple or complex is done, my strategy would be something like this:

function fill_list_of_results(list_of_inputs, array_type, fn_to_call)
    # Query the first result
    first_result = fn_to_call(list_of_inputs[begin])

    # Allocate the array
    R = typeof(first_result)
    V = array_type{R}
    vec = V(undef, length(list_of_inputs))

    # Build the object to be returned
    result = ListOfResults(vec, "some useful information")

    # Fill the array
    result.vec[begin] = first_result
    for (cur_idx, cur_input) in enumerate(list_of_inputs)
        (cur_idx == firstindex(list_of_inputs)) && continue

        result.vec[cur_idx] = fn_to_call(cur_input)
    end

    return result
end

This implementation works as expected:

# Works!
fill_list_of_results([1, 2, 3], Vector, simple)

# Works too!
fill_list_of_results([1.0, 2.0, 3.0], Vector, complex)

However, I am not entirely satisfied with it. I don’t find the implementation of fill_list_of_results too elegant nor “Julia”-like: it calls the function once, saves the result back and uses the type to build an array, then does a for loop skipping the first element… It’s more complicated than I would have expected, but I am at loss to find a smarter way to do the same. Perhaps I should completely revise the way I’m tackling the problem?

I would like to hear your opinion: is there a cleaner way to do what I need? Thank you!

1 Like

Is it important for you to control the array type?
If not it seems you could simply do

map(fn_to_call, list_of_inputs)

letting the standard library handle determining the type of array that should be allocated.

If you want more control it might still be possible but I’m guessing the solution then depends on what you’re trying to achieve.

2 Likes

I frequently find this kind of situation as well. I’m not sure if there’s a perfectly looking solution. Maybe the one I like most is the one where an initial value can be provided but is computed by default:

julia> function results2(f, x; init=f(first(x)))
           results_vec = Vector{eltype(init)}(undef, length(x))
           for i in eachindex(x)
               results_vec[i] = f(x[i])
           end
           return results_vec
       end
results3 (generic function with 1 method)

julia> @btime results2($(sin), $(rand(1:10, 10^3)));
  4.071 μs (1 allocation: 7.94 KiB)

This does not work well, though, if computing a single value is something very expensive. Then I probably would go with something like you posted or, to make the loop a little more self-contained:

julia> function results(f, x)
           local results_vec
           for i in eachindex(x)
               r = f(x[i])
               if i == firstindex(x)
                   results_vec = Vector{eltype(r)}(undef, length(x))
               end
               results_vec[i] = r
           end
           return results_vec
       end
results (generic function with 1 method)

julia> @btime results($(sin), $(rand(1:10, 10^3)));
  4.285 μs (1 allocation: 7.94 KiB)

ps: I have nothing against the use of map, the thing is that I could not find out how it defines the output type, thus one can be unsure if it will be the right choice for the application in question, except for benchmarking. It seems that it does not evaluate the function in advance:

julia> my_expensive_function(x) = begin sleep(1); sin(x) end
my_expensive_function (generic function with 1 method)

julia> @time map(my_expensive_function, [1,2,3])
  3.006254 seconds (14 allocations: 496 bytes)
3-element Vector{Float64}:
 0.8414709848078965
 0.9092974268256817
 0.1411200080598672

julia> @time results(my_expensive_function, [1,2,3])
  3.005756 seconds (14 allocations: 496 bytes)
3-element Vector{Float64}:
 0.8414709848078965
 0.9092974268256817
 0.1411200080598672
1 Like

What about a list comprehension?

2 Likes

I used this function in the past to decide the eltype:

function infer_eltype(itr)
    T1, T2 = eltype(itr), Base.@default_eltype(itr)
    ifelse(T2 !== Union{} && T2 <: T1, T2, T1)
end

it’s not perfect though as can be seen by the discussion in this PR: Create `infer_eltype` function by Tortar · Pull Request #54909 · JuliaLang/julia · GitHub and the associated issue

1 Like

The annoying part is that the desired behavior permits users to specify GPU arrays as the destination.

I think there is no “simple julian” single function that:

  1. Needs no explicit element/result type from the callsite
  2. Robustly avoids unnecessary CPU ↔ GPU transfers (and runs itself on the GPU)

If you are happy with Vector results and on-CPU computation, map or list comprehension does the job just fine. This works with any crap fn_to_call.

If you need to robustly support GPU, then you cannot feed in any crap fn_to_call and your user needs to think about types and whether things reside on device or host memory.

So the best you can do is have two APIs: A simple one, using map that allocates a vector, and a more complex one that requiresusers to pre-allocate the destination.

2 Likes

There is internal machinery to collect a generator (akin to a list comprehension) but with support for using similar to allocate the array(s) as necessary. It works quite well; it just needs docs and public-ation.

But the “hard part” here — incremental widening — is something that I don’t think you’d be able to do very effectively on a GPU anyway, right? Just look at the first element’s result and generate a similar array with the type of that value and go. You can use Iterators.drop to skip that first element in the subsequent for loop.

2 Likes

Thank you all for your answers. Yes, I already ruled out map and list comprehension as callers might want to use some other data structure than Array.

@lmiq It helps me a lot to hear that other people faced similar problems, guess that my approach is not so bad, after all. Thanks for the benchmarks, in fact my case could take some time to compute a single value, so it’s better not to recompute it.

@foobar_lv2, a duplicated API sounds nice. In fact, I reckon that most of the people that will use this code will run it on a CPU, so this would make their life easier.

@mbauman, I’ll take the hint about Iterators.drop, thanks!