Copying the bytes of an `NTuple{N,UInt8}` into a `Memory{UInt8}` or `Vector{UInt…8}` is 3–10× slower than achievable. There are two compounding causes:
1. There is no specialized `copyto!` for tuple sources, so `copyto!(::Memory, ::NTuple)` falls back to the generic iterate-based `copyto!(dest::AbstractArray, src)` (`base/abstractarray.jl:935` on 1.12).
2. Writing the loop by hand does not help much either, because **copy loops with a tuple source are not vectorized**, while the identical loop with an array source is.
Reproduced identically on 1.12.6, 1.13.0-rc1 and 1.14.0-DEV.2306.
## Codegen
```julia
using InteractiveUtils
vec(ir) = occursin(r"<\d+ x i8>", ir)
mcpy(ir) = occursin("llvm.memcpy", ir)
function ir_of(f, sig)
io = IOBuffer(); code_llvm(io, f, sig; optimize=true, debuginfo=:none); String(take!(io))
end
@noinline function tuple_to_memory(src::NTuple{N,UInt8}) where N
dst = Memory{UInt8}(undef, N)
@inbounds for i in 1:N; dst[i] = src[i]; end
return dst
end
@noinline function memory_to_memory(src::Memory{UInt8}, dst::Memory{UInt8})
@inbounds for i in eachindex(src, dst); dst[i] = src[i]; end
return dst
end
@noinline function vector_to_vector(src::Vector{UInt8}, dst::Vector{UInt8})
@inbounds for i in eachindex(src, dst); dst[i] = src[i]; end
return dst
end
for (name, ir) in (("NTuple{256} -> Memory", ir_of(tuple_to_memory, (NTuple{256,UInt8},))),
("Memory -> Memory", ir_of(memory_to_memory, (Memory{UInt8}, Memory{UInt8}))),
("Vector -> Vector", ir_of(vector_to_vector, (Vector{UInt8}, Vector{UInt8}))))
println(rpad(name, 24), "memcpy: ", rpad(string(mcpy(ir)),6), " vectorized: ", vec(ir))
end
```
Identical output on all three versions:
```
NTuple{256} -> Memory memcpy: false vectorized: false
Memory -> Memory memcpy: false vectorized: true
Vector -> Vector memcpy: false vectorized: true
```
The tuple source is the discriminator. It makes no difference whether the destination is freshly allocated or preexisting.
This is not a case of the source being unaddressable. At every size I checked (N = 4, 8, 16, 32, 64, 256) the tuple argument arrives as
```llvm
ptr nocapture noundef nonnull readonly align 1 dereferenceable(N) %"src::Tuple"
```
and a freshly allocated destination is `noalias`. For N ≤ 64 the loop fully unrolls into N scalar `load i8`/`store i8` pairs; at N = 256 it stays a scalar loop. Neither form becomes a `memcpy`.
## Timings
`tuple -> Memory{UInt8}`, including the `Memory` allocation, via BenchmarkTools at `-O3`. `bulk` is the prototype below.
Julia 1.12.6:
| N | bulk (prototype) | `copyto!` today | hand loop |
|---|---|---|---|
| 8 | 10.9 ns | 25.3 ns | 12.3 ns |
| 64 | 30.6 ns | 97.5 ns | 28.5 ns |
| 256 | **92.8 ns** | 320.1 ns | 197.0 ns |
| 1024 | **111.3 ns** | 1088.2 ns | 736.3 ns |
Julia 1.14.0-DEV.2306:
| N | bulk (prototype) | `copyto!` today | hand loop |
|---|---|---|---|
| 8 | 10.8 ns | 21.5 ns | 12.1 ns |
| 64 | 30.5 ns | 77.9 ns | 59.0 ns |
| 256 | **96.7 ns** | 234.9 ns | 142.1 ns |
| 1024 | **152.3 ns** | 870.8 ns | 516.2 ns |
## Prototype
```julia
@inline function bulk_copyto!(dest::AbstractVector{T}, doffs::Int,
src::NTuple{N,T}, soffs::Int, n::Int) where {T,N}
@boundscheck begin
isbitstype(T) && sizeof(NTuple{N,T}) == N*sizeof(T) || throw(ArgumentError("layout mismatch"))
1 <= soffs && soffs+n-1 <= N && 1 <= doffs && doffs+n-1 <= length(dest) || throw(BoundsError())
end
ref = Ref(src)
GC.@preserve ref dest begin
p = Ptr{T}(Base.unsafe_convert(Ptr{Cvoid}, ref)) + (soffs-1)*sizeof(T)
unsafe_copyto!(pointer(dest, doffs), p, n)
end
return dest
end
```
Materialize once with `Ref`, then bulk copy. In the benchmarks above the `Ref` is fully elided — reported allocations equal the `Memory` alone.
One convenient property: because the source is an immutable value, `Ref(src)` is a fresh copy and the destination can never alias it. Unlike array→array `copyto!`, no overlap handling is needed.
## Possible directions
1. **Add specialized methods** — `copyto!(dest::Memory{T}, src::NTuple{N,T})` plus the `Array` and `(doffs, soffs, n)` forms, guarded on `isbitstype(T) && sizeof(NTuple{N,T}) == N*sizeof(T)`. Small and immediately effective.
2. **Fix the codegen** so tuple-sourced loops optimize like array-sourced ones. Broader benefit. A specialized method would still be worth having, since it guarantees a `memmove` instead of relying on optimizer heuristics.
Happy to open a PR for (1) if that is the preferred direction.
## Related
- #11899 "SLP vectorization not working for tuples" (closed 2018)
- #15482 "Tuples getting unnecessarily copied to functions just reading a value"
## Versioninfo
```
Julia Version 1.12.6
Commit 15346901f00 (2026-04-09 19:20 UTC)
Build Info: Official https://julialang.org release
Platform Info:
OS: Linux (x86_64-linux-gnu)
CPU: 8 × AMD FX(tm)-8350 Eight-Core Processor
WORD_SIZE: 64
LLVM: libLLVM-18.1.7 (ORCJIT, bdver1)
```
Also checked on 1.13.0-rc1 and 1.14.0-DEV.2306 (same machine). Note this is an older microarchitecture (`bdver1`); the tuple-vs-array comparison is internally controlled, but I have not confirmed the same codegen on AVX2/AVX-512 hardware.
🤖 Generated with [Claude Code](https://claude.com/claude-code)