How to avoid allocations when reading arbitrary keys from a NamedTuple?

I have a situation where I’m processing rowwise data from a JuliaDB table in a reducer. I’m having trouble with type stability, because my function looks like:

function (row, col)
   value = row[col]
   ...do things with value...
end

This is not surprising because Julia doesn’t know at compile time which column y will be and, and therefore what type it is, and therefore it is unstable and causing allocations. But I know that x[y] is an Int64, so I tried annotating the type:

function (row, col)
   value::Int64 = row[col]::Int64
   ...do things with value...
end

but it is still unstable. How can I annotate row[col] to avoid allocations?

Here’s a MWE (if you enclose the whole thing in a function, the allocations go away for both functions, but only because Julia can infer the value and type of t at compile time - which doesn’t solve my overall problem):

t = (abc=1, tba=2)

unstable(x, i) = x[i]
annotated(x, i) = x[i]::Int64

# run once to compile
unstable(t, :abc)
annotated(t, :abc)

println("no type annotation, allocation expected")
@time unstable(t, :abc) # ->  0.000018 seconds (1 allocation: 32 bytes)

println("type annotation, no allocation expected")
@time annotated(t, :abc) # ->   0.000004 seconds (1 allocation: 32 bytes)

That sounds suspicious to me, because it would be the first thing to try. Better MWE then?

1 Like

You’re right, that was a bad example. Here’s a better one. The critical thing is that which key is to be extracted from the NamedTuple is not known at compile time, and that the NamedTuple has values that are not all the same type. Here’s a better example, with the key read from the command-line arguments:


unstable(x, i) = x[i]
annotated(x, i) = x[i]::Int64


function main()
    t = (abc=1, tba=2.0, cd="jgds")

    # run once to compile
    unstable(t, :abc)
    annotated(t, :abc)

    ref = Symbol(ARGS[1])

    println("no type annotation, allocation expected")
    @time unstable(t, ref)

    println("type annotation, no allocation expected")
    @time annotated(t, ref)

end

main()

Running julia namedtuple_test.jl abc results in:

no type annotation, allocation expected
  0.000001 seconds (1 allocation: 32 bytes)
type annotation, no allocation expected
  0.000001 seconds (1 allocation: 32 bytes)

The first allocation is expected, because Julia has no way to know what will come out of the NamedTuple, and not all fields are bitstypes, so it is type-unstable. In the second one, I explicitly tell Julia that it’s going to be an Int64, so I would expect there to not be any allocations in that case, but there still is one.

it’s just a type assertion, and as you’ve seen, the allocation happens before the assertion (during the getindex).

Maybe this helps:

using BenchmarkTools

access(x, i) = x[i]

function test1(symbol)
    t = (abc=1, tba=2.0, cd="jgds")

    access(t, symbol)
end

dispatch(::Val{:abc}, x) = access(x, :abc)
dispatch(::Val{:tba}, x) = access(x, :tab)
dispatch(::Val{:tba}, x) = access(x, :cd)

function test2(symbol)
    t = (abc=1, tba=2.0, cd="jgds")

    dispatch(Val(symbol), t)
end

symbol = :abc
@btime test1(symbol)
@btime test2(symbol)

with

  36.657 ns (1 allocation: 32 bytes)
  184.203 ns (0 allocations: 0 bytes)

I think the allocation that you observe with unstable is a benchmarking artifact. If you use BenchmarkTools, it goes away:

using BenchmarkTools
t = (abc=1, tba=2.0)
unstable(x, i) = x[i]
julia> @btime unstable($t, :abc);
  0.039 ns (0 allocations: 0 bytes)

Of course now we see another benchmarking artifact, which is the sub-nanosecond runtime. We can fix that with the Ref trick:

julia> @btime unstable($(Ref(t))[], :abc);
  1.472 ns (0 allocations: 0 bytes)

As long as you’re using unstable in a local scope with local variables, then it should be type-stable.

1 Like