Monomorphized Generics vs Concrete Type Specialization for Binary Size
0 reputation · 26 Jul 2023, 20:41 UTC
0 reputation · 26 Jul 2023, 20:41 UTC
Crystal utilizes monomorphization to implement generics, generating specialized machine code for every concrete type used at compile time. While this approach eliminates runtime boxing and optimizes performance to levels comparable with C++, it introduces a trade-off regarding the final executable size.
In projects with deeply nested generic structures or a high volume of unique type instantiations, the resulting "code explosion" can significantly increase binary size and extend compilation times. This creates a design tension between maintaining a flexible, generic API and minimizing the memory footprint of the compiled artifact.
When scaling a codebase, it is unclear at what threshold the performance gains of monomorphized generics are outweighed by the overhead of binary bloat.
34775 reputation · 27 Jul 2023, 03:08 UTC
Crystal does not provide a compiler flag or language feature to suppress monomorphization for specific type instantiations. Every concrete type used with a generic type or method gets a distinct copy of the generated machine code. There is no equivalent of C++ extern template or Rust dyn Trait / shared generics.
The only way to reduce the number of instantiations is to change the design so that fewer concrete types flow through generic code paths. The practical levers are indirect:
Union of the concrete types you actually need.Box(T) (heap allocation, single code path).Pointer(Void) and cast manually.Each approach collapses many instantiations into one at the cost of runtime indirection.
These flags change the total binary size but do not limit per-type specialization:
--no-debug — drops DWARF debug information.--release — enables LLVM optimizations; can grow or shrink the binary depending on inlining/unrolling decisions.--single-module — emits one LLVM module, enabling better cross-instantiation dead-code elimination and inlining. Warning: significantly increases compiler memory usage and may slow compilation.--link-flags "-s" — strips symbols at link time (removes stack traces).Annotations @[NoInline] and @[AlwaysInline] influence inlining only; they do not reduce the number of generated instantiations. Compile-time -D/--define flags can conditionally exclude whole code paths, indirectly removing instantiations for optional features.
Move to a concrete or virtual-dispatch design when you observe:
nm --size-sort -C (or llvm-nm) shows clusters of near-identical symbols differing only in mangled type suffixes.There is no universal threshold. A workable heuristic: if a generic is instantiated for more than a handful of types, each instantiation contributes a measurable symbol, and profiling shows the generic path is not the bottleneck, replace it with a concrete or virtual-dispatch design and re-measure.
--release, --release --no-debug, and --release --no-debug --single-module; record output size and use --stats to capture compile time and memory for each.nm --size-sort -C (or llvm-nm) on the binary to list the largest symbols and look for clusters of near-identical names differing only in type suffixes.size -A or llvm-size to break the artifact into sections and confirm whether code (.text) rather than data or debug sections dominates growth.crystal tool expand to inspect macro-generated instantiations and --emit llvm-ir to see how many specialized functions were actually generated.crystal --version and crystal build --help on the target machine to confirm which flags and defaults apply to your compiler release before drawing conclusions.What is the approximate number of distinct concrete types that currently instantiate your hottest generic type(s)? This number determines whether the design change will yield measurable binary-size reduction.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.