Describe the bug
With the default (V3) compiler, a << x copies in depth every array that the struct x owns (a derived clone, recursive) before the push, when x is a variable or a field. An array literal [x], a << [x] and a << f() do not copy, and neither does -old-compiler in any of these forms.
So the same value is shared or copied depending on how the append is written, programs that built nested values with the previous compiler now pay a copy of the whole value at every push, and the cost of a statement that looks O(1) is the size of everything reachable from x.
I know the clone is deliberate: clone_borrowed_array_append_value in vlib/v/transform/array.v says "The non-ownership lowering clones it to preserve ordinary V value semantics". I am reporting the inconsistency between the forms and the cost, and asking which behaviour is intended.
Reproduction Steps
struct Node {
mut:
kids []Node
n int
}
fn main() {
mut inner := Node{
kids: [Node{
n: 1
}]
}
literal := [inner]
mut pushed := []Node{}
pushed << inner
mut pushed_many := []Node{}
pushed_many << [inner]
inner.kids[0].n = 2
println('array literal shares the nested array: ${literal[0].kids[0].n == 2}')
println('push of one element shares it: ${pushed[0].kids[0].n == 2}')
println('push of an array shares it: ${pushed_many[0].kids[0].n == 2}')
}V_C_ERROR_BUG_REPORT_DISABLED=1 V_MACOS_V3_NO_FALLBACK=1 v run main.v
Expected Behavior
The three forms agree, as they do with v -old-compiler run main.v:
array literal shares the nested array: true
push of one element shares it: true
push of an array shares it: trueor, if an append is meant to give the element storage of its own, all three do that. Either way, an append whose source is not used afterwards should not copy it.
Current Behavior
array literal shares the nested array: true
push of one element shares it: false
push of an array shares it: trueThe generated C for pushed << inner:
main__Node __derived_clone_2 = inner;
Array __derived_clone_array_3 = array__clone(&__derived_clone_2.kids);
{
i64 __derived_clone_array_idx_4 = 0;
for (; __derived_clone_array_idx_4 < __derived_clone_array_3.len; __derived_clone_array_idx_4++) {
{ ... array__set(_a0, _i0, &(main__Node[]){__v3_default_clone_Node(...)}); }
}
}
__derived_clone_2.kids = __derived_clone_array_3;
main__Node __arr_val_5 = __derived_clone_2;
array_push(&pushed, &__arr_val_5);while literal := [inner] is new_array_from_c_array(1, 1, sizeof(main__Node), (main__Node[1]){inner}) and pushed_many << [inner] is a plain array__push_many.
The cost. A value built bottom-up, with each level appending the two values of the level below:
import time
struct Node {
kids []Node
n int
}
fn build(level int) Node {
if level == 0 {
return Node{
n: 1
}
}
a := build(level - 1)
b := build(level - 1)
mut kids := []Node{}
kids << a
kids << b
return Node{
kids: kids
}
}
fn leaves(node Node) int {
if node.kids.len == 0 {
return node.n
}
mut total := 0
for kid in node.kids {
total += leaves(kid)
}
return total
}
fn main() {
for level in [16, 18, 20] {
sw := time.new_stopwatch()
root := build(level)
println('${level} levels: ${leaves(root)} leaves in ${sw.elapsed().milliseconds()} ms')
}
}With -prod, on one core:
| levels | V3 | -old-compiler |
|---|---|---|
| 16 | 48 ms | 6 ms |
| 18 | 269 ms | 29 ms |
| 20 | 1867 ms | 228 ms |
Each value is copied whole at every level, so the work is n·2ⁿ instead of 2ⁿ, and all of it is allocation. I found it profiling a real program (a linter whose constant evaluator builds nested array values with out << v.value in a loop): one input took 28.4 s, and 12.4 s once those two appends did not copy.
Possible Solution
Whatever the intended semantics, the clone is not needed when nothing can reach the source afterwards. v := f(); a << v.x is a << f().x, which is not cloned today.
I have a conservative patch for that in the transformer (about 160 lines in vlib/v/transform/array.v, next to clone_borrowed_array_append_value, with a behaviour test and a codegen test). It skips the clone when the appended value is an immutable local, or a field path of one, that:
- is declared from an expression whose address cannot be taken (a call, a literal), in a statement before the append with no loop, function literal,
deferorspawnbetween the two; - is not used after the append, and whose other uses are earlier reads of values that own no storage (
v.found), outside a function literal or adefer.
With it the benchmark above takes 8 ms, 32 ms and 283 ms, the reproduction prints the same as today (its source is mut and read afterwards, so it is still copied), and ten cases where the copy must stay (read after, mut, declared from a variable, appended in a loop, captured by a closure, read in a defer, storage copied out before, a field appended twice…) behave as before. I have not run the whole test suite with it. I can open a PR if this direction is welcome; a real last-use analysis, or making the three forms agree, would cover more.
Additional Information/Context
- The clone only happens for an addressable source (
expr_can_take_address), which is whya << f()anda << [x]are not affected, and why writingout << [v.value]instead ofout << v.valueis a workaround. - A plain struct copy (
y := x) shares the nested arrays too, with both compilers. - In an ownership build of the compiler (
-d ownership) that function returns the value without cloning it when the checker says the append moves it (ownership_expr_moves_storage), so this is about the default build.
V version
V 0.5.2 3887674 (upstream master on 2026-10-13)
Environment details
- OS: Linux x86-64 (CachyOS), glibc 2.44
- cc / gcc: GCC 16.2.1 20260810
Activity
0No activity yet.