Runtime bytecode generation (ASM) for Globs. Two independent things live here, and they are worth different amounts:
- generated Glob classes — one
Globimplementation perGlobType, values in real Java fields (private String name;) instead of theObject[]of core'sDefaultGlob; - generated callers — the traversal a codec does over a Glob, emitted unrolled so each per-field call site is monomorphic and inlines.
Nothing here is meant to be called directly: the module plugs into core through the GlobFactoryService,
FromGlobCallerService and ToGlobCallerService extension points, all three selected by a system property
holding a class name.
The short version: generating the Glob class alone is neutral at best and usually a small loss. The caller is what pays — and it does not need the generated class to pay. See
PERFORMANCE.mdfor the numbers gathered across the workspace.
- Java 21 (the generated bytecode targets
V17) org.globsframework:globs>= 5.11 (the per-type annotation is read fromDefaultGlobType's constructor, which only stores its annotations early enough from that version), andorg.ow2.asm:asm
<dependency>
<groupId>org.globsframework</groupId>
<artifactId>globs-generate</artifactId>
<version>5.4.0</version>
</dependency>The module only has to be on the classpath; what it does is decided by the properties below.
The interfaces (FromGlobCaller, FromGlobFunction, ToGlobCaller, KeySource, ...) live in core, in
org.globsframework.core.model.caller, so a codec is written against them without depending on this module.
Core ships looped implementations; this module generates them.
A codec asks for a caller once, at setup, and gets a class with one public static final FromGlobFunction
per field and a call unrolled over them. A static final read is a JIT constant, so every
fn_i.call(...) sees a single receiver and inlines, where the one call site of a hand-written loop sees
every function of every field of every type in the process and stays megamorphic.
// reading a Glob out (serialization)
FromGlobCaller<Out, Void> caller = FromGlobCallerFactory.callerFor("mycodec.write", type, functions);
caller.call(glob, out, null); // -> fn_i.call(isSet, isNull, value, ctx1, ctx2)
// writing a Glob in (parsing)
ToGlobCaller<In, Void, Void> reader = ToGlobCallerFactory.get()
.create("myformat.read." + type.getName(), functions, skipUnknown, -1);
reader.call(parser, type.instantiate(), in, null, null);callerFor falls back to core's loop over the same function table for a type that has no generated class —
same order, same isSet/isNull/value, so the codec keeps one code path and only the speed changes.
Activation, both independent of each other and of globs.builder:
-Dglobs.caller.fromGlob=org.globsframework.model.generator.AsmCallerGeneratorService
-Dglobs.caller.toGlob=org.globsframework.model.generator.AsmCallerWriteGeneratorService
Measured against the looped fallback (FromGlobCallerPerf): x4 to x4.9 at 4, 20 and 40 fields, on both
flavours. In real codecs: globs-fix write +31 %, globs-bin-serialisation read +17 %.
AsmCallerGenerator.forDefaultGlob(type) emits the same unrolled caller over core's DefaultGlob32/64/128:
only the traversal is generated, the application's own glob.get(F) keeps going through core's array
access. That is usually the configuration to prefer — see the warning below.
The generator buys the first call site. The second one — the codec, the delegate, the nested caller the
function holds in a field — is folded by C2 only for a class it trusts with its final instance fields:
records, hidden classes and therefore lambdas. A FromGlobFunction written as an ordinary named
class with final fields throws away about half of what the generator bought (5.40 ns vs 0.51 ns per 4 calls
in a microbenchmark; +4 % and +6.7 % on the real codecs when they were converted).
The reverse holds where a whole sub-tree hangs off the call: keeping a Glob-valued writer's descent unfoldable acts as an inlining barrier and is worth +12 % on nested-heavy shapes. Fold the leaves, keep a boundary between compilation units.
Core picks its GlobFactoryService from the globs.builder system property (a fully-qualified class
name, not a flag):
| Property value | Fields hold | Null representation |
|---|---|---|
org.globsframework.model.generator.object.GeneratorGlobFactoryService |
boxed types (Integer, Double, ...) |
the reference itself is null |
org.globsframework.model.generator.primitive.GeneratorGlobFactoryService |
native types (I, D, J, Z) |
a separate isNull bitmask |
Both fall back to core's DefaultGlobFactory above 64 fields (the masks of the generated bases are 64
bits wide at most). Accessors (GlobGetAccessor / GlobSetAccessor) are generated with the class, one
class per field and direction, reading the Glob's fields directly: getNative x1.8, setNative x1.4,
String get/set x1.35, isSet x1.47 against the doGet-based ones.
Warning.
doGet/doSeton a generated Glob are atableswitchover every field, so their bytecode grows with the field count (object flavour:doSet214 bytecodes at 10 fields, 384 at 20, 724 at 40). PastFreqInlineSize(325) a hot callee is never inlined, where core's array load always is. A type wider than ~15-20 fields therefore turns everyglob.get(F)in the application into a real call, and narrow types inline a whole tableswitch per access. This is why generating the class measures as a loss inglobs-fix(-4 to -10 %) andglobs-grpc(half the throughput), and whyforDefaultGlobexists.
The installed service only sets the default. A type carrying the GeneratedOption annotation gets what it
asks for instead — including the other flavour, or nothing at all:
GlobTypeBuilder builder = GlobTypeBuilderFactory.create("MyType");
builder.addAnnotation(GeneratedOption.primitive(false)); // primitive Glob, doGet-based accessors| Setting | Annotation field | Property when unset | Values |
|---|---|---|---|
| what to generate | mode |
globs.generate.mode |
none | object | primitive |
| generate the accessors | accessors |
globs.generate.accessors |
true | false |
The two are merged field by field — an annotation's unset is not false, so GeneratedOption.primitive()
pins the flavour and still leaves the accessors to the deployment. none makes the service answer null,
which is how a GlobFactoryService says "not mine": core falls back to DefaultGlobFactoryService, exactly
an unconfigured JVM.
Activation fails silently — a JVM where nothing is generated still works, it is only slower or faster.
Assert on the class of what comes out of instantiate() when checking a configuration, as
GeneratedFactoryActiveTest does. The service is cached, so a test switching flavours inside one JVM must
call GlobFactoryService.Builder.reset().
mvn -o test # both generator flavours
mvn -o test -Dtest=AsmGlobPrimitiveGeneratorTest
mvn -o install # publish locally for downstream modulesThe JMH benchmarks in src/test/.../generated/perf (SerializerPerf, AccessorPerf, VisitorUnrollPerf,
FromGlobCallerPerf, ToGlobCallerPerf) have no exec binding:
mvn -o test-compile dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
java -cp target/classes:target/test-classes:$(cat /tmp/cp.txt) org.openjdk.jmh.Main FromGlobCallerPerfCLAUDE.md documents how generation works, the class naming and the invariants the tests pin down;
PERFORMANCE.md collects the measurements from every module that adopted this.
Apache License 2.0 — see https://www.apache.org/licenses/LICENSE-2.0.txt.