#29817

v3, orm: per-model monomorphization costs ~0.19 s and ~120 KB of C (47 models with one `select` each → 8.9 s in `monomorphize`)

OpenJengro777opened 21d ago

Summary

sql db { select from M } expands into a per-model set of generic instantiations (new_query[T], new_query_internal[T], struct_meta[T], table_from_struct[T] and QueryBuilder[T] methods including map_row). The cost is essentially fixed per distinct model type, not per query:

  • 1 model, 1 select → 0.65 s in monomorphize, 719 KB C, 14 orm__ symbols for that one model
  • 47 models, 1 select each → 8.92 s, 6.39 MB C
  • 47 models, 3 selects each → 11.22 s (+2.3 s for 2× the query sites)

Slope: ~0.19 s and ~120 KB of C per added model type.

Environment

  • V 0.5.2 5bd6709 (vlang/v master), Linux x86_64
  • v -new-compiler -cc tcc -no-retry-compilation
  • Compile to C (-o out.c) so only the V pipeline stages are measured; timings from -show-timings; ±5% run-to-run variance
  • No database needed — the repro only compiles

Minimal case (single file, one model)

module main

import orm
import time
import os

@[table: 't0']
pub struct M0 {
pub:
	id         string @[primary; immutable; sql_type: 'CHAR(36)']
	name       string @[sql_type: 'VARCHAR(255)']
	code       string @[sql_type: 'VARCHAR(64)']
	qty        int
	sort       u32
	status     u8
	score      f64
	rate       f64
	flag       bool
	since      ?int
	note       ?string
	creator_id ?string
	created_at time.Time
	updated_at time.Time
	deleted_at ?time.Time
}

fn q0(db orm.Connection) ![]M0 {
	return sql db { select from M0 }!
}

fn bench_all() {
	db := unsafe { &orm.Connection(nil) }
	_ = q0(db) or { []M0{} }
}

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           645.40 ms
  cgen (parallel)        115.65 ms
  total                 1359.83 ms
    generated C size             718542 bytes

The 14 symbols instantiated for this single select-only model:

orm__QueryBuilder_M0
orm__QueryBuilder_M0__ensure_keys_for_includes
orm__QueryBuilder_M0__map_row
orm__QueryBuilder_M0__prepare
orm__QueryBuilder_M0__query
orm__QueryBuilder_M0__reset
orm__QueryBuilder_M0__selected_primary_value
orm__QueryBuilder_M0__v_sql_field_name
orm__QueryBuilder_M0__v_sql_select_fields
orm__QueryBuilder_M0__v_sql_table_attrs
orm__new_query_T_M0
orm__new_query_internal_T_M0
orm__struct_meta_T_M0
orm__table_from_struct_T_M0

Scaling reproduction

gen.sh generates N models, each used by exactly one select:

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

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

@[table: 't$i']
pub struct M$i {
pub:
	id         string @[primary; immutable; sql_type: 'CHAR(36)']
	name       string @[sql_type: 'VARCHAR(255)']
	code       string @[sql_type: 'VARCHAR(64)']
	qty        int
	sort       u32
	status     u8
	score      f64
	rate       f64
	flag       bool
	since      ?int
	note       ?string
	creator_id ?string
	created_at time.Time
	updated_at time.Time
	deleted_at ?time.Time
}

fn q$i(db orm.Connection) ![]M$i {
	return sql db { select from M$i }!
}
EOF
	i=$((i + 1))
done
echo ''
echo 'fn bench_all() {'
echo '	db := unsafe { &orm.Connection(nil) }'
i=0
while [ "$i" -lt "$N" ]; do
	echo "	_ = q$i(db) or { []M$i{} }"
	i=$((i + 1))
done
echo '}'
echo ''
echo 'fn main() {'
echo '	if os.args.len > 100 {'
echo '		bench_all()'
echo '	}'
echo '}'
$ sh gen.sh 47 > main.v
$ v -new-compiler -cc tcc -no-retry-compilation -show-timings -o out.c main.v

  monomorphize          8918.86 ms      271 MB RSS      831 MB stage peak
  cgen (parallel)        850.77 ms      248 MB RSS      327 MB stage peak
  total                10447.28 ms
    generated C size             6389867 bytes
modelsquery sitesmonomorphizecgentotalgenerated C
11 × select0.65 s0.12 s1.36 s0.72 MB
88 × select1.53 s0.22 s2.36 s1.58 MB
4747 × select8.92 s0.85 s10.45 s6.39 MB
4747 × 3 selects11.22 s1.13 s13.16 s7.62 MB

The 3-select variant only adds two lines to each q$i:

	_ := sql db { select count from M$i }!
	_ := sql db { select from M$i where id == 'x' limit 1 }!

Expected

Per-model cost should be much lower or amortized across models — e.g. row mapping and metadata for a given struct shape generated once and shared, instead of ~14 generic instantiations per model with map_row being the largest.

Where the cost is

  • vlib/orm/orm_func.v:922 struct_meta[T]() — comptime $for field in T.fields reflection
  • vlib/orm/orm_func.v:1047 map_row[T] — in a real 47-model app this single family is 111k C lines (~2.4k lines per model)
  • vlib/orm/orm_func.v:81/85 new_query[T] / new_query_internal[T]
  • The time is in monomorphize (8.9 s) rather than cgen (0.85 s), so the bottleneck is specialization in the transform stage, not C emission — drain_pending_generic_fn_specs profiling per spec may be the right place to look

Impact

A private 24k-line veb + orm + json2 app: ORM functions are ~197k of 528k generated C lines (37%); an identical second build still spends 73 s in monomorphize + 40 s in cgen.

Notes

  • Steady-state cost, not the non-convergence/hang fixed in #29764.
  • Related: #29304 / #29311 (comptime reflection AST copies, fixed), #29354 / #29364 (json2 per-struct cost, fixed).

Happy to test patches or provide the generated main.v for any N.

Activity

0

No activity yet.