Thank you @vlaviron!
I spent the morning investigating and I think I have a basic idea of what is happening now.
I used Claude to write a bash script that generates a source module Amin with some large number of n submodules of the form module M_k = module type t = int end and then a client module which accesses Amin.M_0.t. You can see that the compile time is quadratic with n.
Here is the code:
#!/bin/sh
# Quadratic typing time when a client touches one submodule of a file
# containing N independent submodules. OCaml 5.5.0.
OC=${OCAMLOPT:-ocamlopt.opt}
printf 'type x = Amin.M_00000.t\n' > cmin.ml
printf '%-7s %-14s %s\n' N client_typing ratio
prev=""
for N in 1000 2000 4000 8000 16000; do
awk -v n=$N 'BEGIN{for(i=0;i<n;i++)printf "module M_%05d = struct type t = int end\n",i}' > amin.ml
$OC -opaque -c amin.ml 2>/dev/null
t=$($OC -opaque -dtimings -c cmin.ml 2>&1 | awk '/ typing/{gsub(/s/,"",$1);print $1;exit}')
if [ -n "$prev" ]; then r=$(awk -v a=$t -v b=$prev 'BEGIN{printf "%.2fx",a/b}'); else r="-"; fi
printf '%-7s %-14s %s\n' "$N" "${t}s" "$r"
prev=$t
done
I went spelunking into the compiler code to understand what was causing this, and here is my conclusion.
A cmi contains unique stamps associated to each declaration in that compilation unit. So, for example, the module M_3 carries some unique identifier, and is represented internally as M_3/3832 or whatever.
When the module is loaded, these unique identifiers are “lazily reassigned” in order to make them globally unique among all other compilation units which have been loaded in order to compile the target. This is in rename_bound_idents (subst.ml:594) By “lazily reassigned”, we mean that a map $s$ is constructed which associates to each original unique id a new unique id, and the declaration within the module is now represented as something like a pair (original_id, s), which can be at any time evaluated to get the new globally unique id, original_id[s].
Now, the client module needs to be able to access values and submodules in the source by their path, and it also needs to be able to typecheck their usage - or, for a submodule, typecheck projections or functor applications - using paths which are intelligible to it. So if A is our upstream compilation unit, and the client wants to access A.B - either to project onto a type A.B.t,
or to use A.B as a functor argument, or whatever - it needs to know the interface of A.B, presented in such a way that all the fields are expressed using paths.
So if A has submodules M_3 and M_5, and the signature of M_3 is something like sig val v : M_5.t end, then
- initially in the compilation unit,
M_3 and M_5 both have unique stamps, say M_3/3284 and M_5/2127
- after
A is compiled, when A is loaded by the compiler for the sake of compiling some downstream module C, fresh identifiers are now introduced for all these symbols (say 5128 and 8675) and now M_3 is represented as something like an ordered pair (M_3/3284, s = { M_3/3284 |-> M_3/5128 , M_5/2127 |-> M_5/8675 })
- also, we build a map which associates to each sub module
M_3, M_5 a prefixed name (Env.prefix_idents), so we have another map s' = { M_3/5128 |-> A.M_3, M_5/8675 |-> A.M_5 }, in order that the client can refer to it
- these maps are composed (subst.ml:741) so we combine
(M_3/3284, s) with s' to get the pair
(M_3/3284, { M_3/3284 |-> A.M_3 , M_5/2127 |-> A.M_5 })
- this composition takes time at
n log n if evaluating each value takes log time
- this happens for every module declared in
A, so it’s O(n^2 * lg n) in the number of modules in A
I don’t think this quadratic complexity is essential, and it could be eliminated in special cases.
Of course no human author is writing files with 3700 submodules. However, I think that OCaml’s language of interfaces is helpful in giving safe access to untyped tools, and I think it’s a legitimate interest to serve the use case of procedurally generated code.
I would be interested to hear if anyone involved in compiler maintenance want to weigh in.
It seems like the same compose s s' is being called repeatedly at each submodule declaration. So maybe that could just be pulled out?