Skip to content

Latest commit

 

History

101 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Globs Generate

Runtime bytecode generation (ASM) for Globs. Two independent things live here, and they are worth different amounts:

  • generated Glob classes — one Glob implementation per GlobType, values in real Java fields (private String name;) instead of the Object[] of core's DefaultGlob;
  • 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.md for the numbers gathered across the workspace.

Requirements

  • Java 21 (the generated bytecode targets V17)
  • org.globsframework:globs >= 5.11 (the per-type annotation is read from DefaultGlobType's constructor, which only stores its annotations early enough from that version), and org.ow2.asm:asm

Installation

<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.

Generated callers — the part that pays

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.

Write the functions as records or lambdas

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.

Generated Glob classes

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/doSet on a generated Glob are a tableswitch over every field, so their bytecode grows with the field count (object flavour: doSet 214 bytecodes at 10 fields, 384 at 20, 724 at 40). Past FreqInlineSize (325) a hot callee is never inlined, where core's array load always is. A type wider than ~15-20 fields therefore turns every glob.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 in globs-fix (-4 to -10 %) and globs-grpc (half the throughput), and why forDefaultGlob exists.

Per-type choice

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().

Building

mvn -o test                                              # both generator flavours
mvn -o test -Dtest=AsmGlobPrimitiveGeneratorTest
mvn -o install                                           # publish locally for downstream modules

The 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 FromGlobCallerPerf

CLAUDE.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.

License

Apache License 2.0 — see https://www.apache.org/licenses/LICENSE-2.0.txt.

Links

About

Use ASM to generate Globs based GlobType

Resources

Stars

0 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages