#29819

v3, json2: residual per-struct cost after #29364 — ~57 ms of `monomorphize` and ~21 KB of C per struct (200 structs → 12.3 s)

OpenJengro777opened 21d ago

Summary

This is a follow-up to #29354 (closed by #29364). The fix already cut the per-struct cost roughly 6× — thanks for that. This issue tracks what remains on current master:

  • 1 struct with decode + encode → 0.89 s in monomorphize, 912 KB of generated C, 9 json2__ symbols for that one struct
  • 200 structs → 12.28 s in monomorphize, 5.01 MB of C
  • marginal cost: ~57 ms of monomorphization and ~21 KB of C per additional struct, essentially linear

The cost is per distinct struct type; source size barely changes between N=1 and N=200.

Environment

  • V 0.5.2 5bd6709 (vlang/v master), Linux x86_64 — includes the #29364 fix (b8194d12ec)
  • v -new-compiler -cc tcc -no-retry-compilation
  • Compiled to C (-o out.c), so only the V pipeline stages are measured; -show-timings; ±5% run-to-run variance
  • No runtime needed — the reproducer only compiles

Minimal case (single file, one struct)

module main

import json2
import time
import os

pub struct D0 {
	f0_name  string
	f0_opt   ?string
	f0_num   int
	f0_small u8
	f0_rate  f64
	f0_ok    bool
	f0_tags  []string
	f0_at    time.Time
	f0_extra ?int
	f0_text  string
	f0_list  []int
	f0_score f64
}

fn bench_all() {
	s0 := '{}'
	d0 := json2.decode[D0](s0) or { D0{} }
	_ = json2.encode(d0)
}

fn main() {
	if os.args.len > 100 {
		bench_all()
	}
}
$ v -new-compiler -cc tcc -no-retry-compilation -show-timings -o out.c minimal.v
  monomorphize           888.00 ms      196 MB RSS      291 MB stage peak
  cgen (parallel)        125.85 ms      183 MB RSS      224 MB stage peak
  total                 1802.08 ms
    generated C size             912479 bytes

The 9 symbols instantiated for this single struct:

json2__Decoder_D0__cached_struct_field_infos
json2__Decoder_D0__decode_root
json2__Decoder_D0__decode_struct_fields
json2__Decoder_D0__decode_value
json2__Encoder_D0__cached_field_infos
json2__Encoder_D0__encode_struct_fields
json2__Encoder_D0__encode_value
json2__decode_T_D0
json2__encode_T_D0

Scaling reproduction

#!/bin/sh
# Usage: sh gen.sh 200 > main.v
N=${1:-200}
cat <<'HDR'
module main

import json2
import time
import os
HDR
i=0
while [ "$i" -lt "$N" ]; do
	cat <<EOF

pub struct D$i {
	f${i}_name  string
	f${i}_opt   ?string
	f${i}_num   int
	f${i}_small u8
	f${i}_rate  f64
	f${i}_ok    bool
	f${i}_tags  []string
	f${i}_at    time.Time
	f${i}_extra ?int
	f${i}_text  string
	f${i}_list  []int
	f${i}_score f64
}
EOF
	i=$((i + 1))
done
echo ''
echo 'fn bench_all() {'
i=0
while [ "$i" -lt "$N" ]; do
	echo "	s$i := '{}'"
	echo "	d$i := json2.decode[D$i](s$i) or { D$i{} }"
	echo "	_ = json2.encode(d$i)"
	i=$((i + 1))
done
echo '}'
echo ''
echo 'fn main() {'
echo '	if os.args.len > 100 {'
echo '		bench_all()'
echo '	}'
echo '}'
$ sh gen.sh 200 > main.v
$ v -new-compiler -cc tcc -no-retry-compilation -show-timings -o out.c main.v

  monomorphize         12280.53 ms      325 MB RSS     1236 MB stage peak
  cgen (parallel)       2506.47 ms      287 MB RSS      547 MB stage peak
  total                15836.55 ms
    generated C size             5011631 bytes
structsmonomorphizegenerated C
10.89 s0.91 MB
81.29 s1.05 MB
322.67 s1.55 MB
966.31 s2.86 MB
20012.28 s5.01 MB

Roughly linear: ~57 ms and ~21 KB of C per additional struct on top of a ~0.8 s framework floor.

Generic wrapper variant

Wrapping every encoded value in a generic ApiSuccessResponse[T] instead of encoding T directly (same 200 structs):

casemonomorphizetotalgenerated C
encode D_i directly12.28 s15.84 s5.01 MB
encode ApiSuccessResponse[D_i]17.51 s25.93 s6.60 MB

So a generic response envelope adds ~24 ms and ~8 KB of C per wrapper type.

Impact

A real 24k-line veb + orm + json2 app has 216 distinct json2.encode and 221 distinct json2.decode instantiations; json2 functions are ~121k of 528k generated C lines (23%). 181 of the encode instantiations are just ApiSuccessResponse[T] wrappers, and an identical second build still spends 73 s in monomorphize + 40 s in cgen.

Where the cost is

  • vlib/json2/encode.v:61 encode_value[T], vlib/json2/encode.v:986 encode_struct_fields[T]
  • vlib/json2/decode.v:391 decode[T] (plus the decode_root / decode_struct_fields helpers per struct)
  • The cached_field_infos / cached_struct_field_infos helpers suggest field metadata is already cached at runtime, but the per-struct generated code (and its specialization cost) remains

Expected

Any of these would help apps with 100+ DTO types:

  • share generated field access/encoding code across structs with the same field-type shape, instead of specializing everything per struct
  • a cheaper path for wrapper generics like ApiSuccessResponse[T] (e.g. reuse the inner encoder rather than specializing the wrapper's field loop)
  • or at least keeping the per-struct marginal cost closer to #29364's level for larger N if the current growth is an artifact of the specialization pipeline rather than inherent

Happy to run any patch against both reproducers (and against a 400+ struct variant) and report before/after numbers.

Activity

0

No activity yet.