Export a JVM Library (jvm-export / --no-main)
A compiled .class is normally a command: java Prog runs its top level and
exits. This guide is about the other shape — a library class whose functions
Java code calls directly. Two pieces make it work:
rontolisp:jvm-exportdeclares a typed, Java-callablepublic staticmethod for adefun, and--no-maindrops themainentry point, so the class is nothing but its exports.
It is the JVM twin of the WASM side's
rontolisp:wasm-export and
--no-wasi reactor mode, and solves the same problem: a compiled defun's
untyped method (public static Object NORM2(Object)) takes and returns
internal representations no Java caller can safely construct — a string
argument passed directly is even silently mis-read, because the internal string
representation is not a bare Java String. The typed wrapper is the safe
boundary.
A library, start to finish
kernels.lisp:
Compile it. A directory in the -o path becomes the class's Java package, and
the directory is created for you:
rontolisp kernels.lisp -o com/example/Kernels.class --no-main
The class now carries exactly the API the directives declared:
package com.example;
public class Kernels {
public static double scaledSum(double a, double b);
public static String greet(String name);
}
And a Java caller uses it like any other class:
import com.example.Kernels;
public class App {
public static void main(String[] args) {
System.out.println(Kernels.scaledSum(2.5, 3.5)); // 12.0
System.out.println(Kernels.greet("ron")); // hello, ron
}
}
javac -cp . App.java && java -cp . App
The method name defaults to the Lisp name lower-camel-cased (scaled-sum
becomes scaledSum); :as "name" picks another. The full designator-to-Java
type table, and the boundary's exact-or-throw conversion rule, are on the
reference page.
The top level runs at class initialization
(defvar *scale* 2.0) above must have run before the first scaledSum call
arrives, and no main will run it. An export-carrying class therefore runs its
top-level forms in the class initializer — once, when the JVM first touches the
class. This is the same design as the --no-wasi reactor, which runs its top
level at instantiation, and it has the same two sharp edges: a top-level form
that signals surfaces as ExceptionInInitializerError (and the class stays
poisoned for the caller's whole JVM), and a top-level (uiop:quit ...)
terminates the calling JVM. Keep a library's top level to definitions and
initialization.
--no-main is orthogonal to the directive: without the flag the class keeps a
main and its exports — a CLI tool that is also a library. main then
does nothing but trigger class initialization, so the program still runs
exactly once. With the flag, at least one jvm-export is required: main is
the only tree-shaker root
otherwise, and a main-less class without exports would shake to nothing.
Exports are extra shaker roots, which is what lets a library keep the default
--optimize size instead of carrying the whole runtime with --optimize=off.
The packed float array
linalg: and vec: values are packed float arrays, and they cross the
boundary as themselves — :float-vector (rank 1) and :float-matrix (rank 2),
both carried by one Java class, am.ik.rontolisp.runtime.RontoFloatArray:
import am.ik.rontolisp.runtime.RontoFloatArray;
import com.example.Kernels;
RontoFloatArray x = RontoFloatArray.of(new double[] { 3.0, 4.0 }); // copies, once
double n = Kernels.norm2(x); // 5.0
RontoFloatArray y = Kernels.axpy(2.0, x, RontoFloatArray.of(new double[] { 1.0, 1.0 }));
double[] out = y.toArray(); // copies out, once
Why a handle and not a double[]. A packed float array is a bare
double[] (or float[]) carrying an embedded dimension header, so a plain
Java array is not one — passing new double[]{3, 4} would compile and answer a
wrong number. Converting one at every call would be safe but costs about ten
times the kernel it feeds, which turns a 3x win into a 3x loss:
| ms/call | vs plain Java | |
|---|---|---|
| plain Java loop, C2 auto-vectorized | 0.89 | 1.00x |
| kernel on a pre-packed array (the floor) | 0.29 | 3.06x |
| kernel behind the handle | 0.29 | 3.12x |
| kernel behind a facade that copies per call | 2.58 | 0.35x |
The handle holds the packed representation across calls: of(...) copies once,
toArray() copies out once, every call in between copies nothing. The
benchmark is examples/jvm/bench/.
A handle aliases the Lisp array. A handle a kernel returns is the very
array the Lisp side holds: set(i, v) through the handle is visible to a Lisp
closure over it, and a Lisp write is visible through get(i). Nothing is
defensively copied — that copy is the last row of the table. of(...) and
toArray() are the two places where a copy happens, and they are the two
places where you asked for one. This also makes destination-passing work:
RontoFloatArray.zeros(...) builds a buffer that a vec:...-into export
writes into, so a Java-side loop allocates nothing per iteration.
Under --gpu, a result stays on the device until you read it. A handle a
--gpu kernel returned is not brought home at the boundary, so a Java-side
chain h = Kernels.step(w, h) leaves every intermediate on the device and only
the read at the end downloads one. Measured over 200 chained GEMVs on a
resident 2048x2048 matrix: 0.070 ms per iteration, the same as the loop that
never leaves Lisp, and one upload for the whole run instead of one per call.
What follows from it is that the read is where the cost sits — the first
get(i) or toArray() on a fresh device result pays the download, later ones
do not — so read a result once rather than element by element.
Both element widths cross the same designator (of(double[]) and
of(float[]); width() reports which), and the rank comes from the header, so
a matrix is the same class with a rank-2 dims() rather than a second type. A
rank the designator does not declare throws at the boundary.
A Maven project: src/main/lisp
If the kernels and the Java that calls them live in the same project, the Lisp does not
have to become an artifact at all — it is just another source set, and Maven already
knows how to package target/classes. One <plugin> block is the whole setup:
<plugin>
<groupId>am.ik.rontolisp</groupId>
<artifactId>rontolisp-maven-plugin</artifactId>
<version>VERSION</version>
<executions>
<execution>
<goals><goal>compile</goal></goals>
<configuration>
<simd>true</simd>
</configuration>
</execution>
</executions>
</plugin>
pom.xml
src/main/lisp/com/example/Kernels.lisp <- the kernels, with their jvm-export declarations
src/main/lisp/com/example/helpers.lisp <- ordinary Lisp the kernels (load ...), no class
src/main/java/app/App.java <- Kernels.scaledSum(...), Kernels.norm2(...)
mvn package # one jar, Lisp classes and Java classes together
A source set is Lisp, not a pile of exports. A file becomes a class exactly when it
declares at least one rontolisp:jvm-export — that is what gives the class an entry point
a Java caller can use. Everything else in src/main/lisp stays ordinary Lisp: the support
code the exported files (load ...), a program you run with the interpreter. It is not
compiled, it is not an error, and it does not have to be named like a class, so
string-utils.lisp is fine beside Kernels.lisp.
The path under src/main/lisp is the class name for the files that do export, exactly
as a .java file's path is: src/main/lisp/com/example/Kernels.lisp becomes
com.example.Kernels, and nothing has to be declared per file. mvn install,
mvn deploy, a Gradle consumer of the resulting jar and an IDE all work with no further
concepts, because the output is ordinary classes in the ordinary place.
The goal runs at process-sources, before javac, because src/main/java compiles
against what it writes — the kernel class and the RontoFloatArray handle type both.
The testCompile goal is its twin: src/test/lisp into target/test-classes, at
process-test-sources.
Every flag that reaches the JVM backend is a parameter under the same name — simd,
blas, gpu, parallel, optimize, dynamic, noPrune, systemPath, dists — and
skip (-Drontolisp.skip=true) turns the goal off. One default differs from the command
line: noMain is on, because a source set is a library, and that is also what makes
an unexported file ordinary Lisp rather than a class. Set <noMain>false</noMain> and
every file gets a main and is compiled, the way rontolisp prog.lisp -o Prog.class
compiles a program.
A compile error is reported as a build failure carrying the rontolisp diagnostic verbatim,
file:line:column: prefix included, so an IDE can jump to it. Compilation is incremental
in maven-compiler-plugin's sense: if nothing is stale, nothing is compiled, and if
anything is, the whole source set is — a (load "...") splices one file into another, so
per-file timestamps cannot be trusted alone.
A war project: src/main/lisp
The same goal also builds a Servlet war, with
one more parameter and a <packaging>war</packaging> project instead of a jar one:
<plugin>
<groupId>am.ik.rontolisp</groupId>
<artifactId>rontolisp-maven-plugin</artifactId>
<version>VERSION</version>
<executions>
<execution>
<goals><goal>compile</goal></goals>
<configuration>
<servlet>true</servlet>
</configuration>
</execution>
</executions>
</plugin>
pom.xml <- <packaging>war</packaging>, a provided jakarta.servlet-api
src/main/lisp/App.lisp <- the rontolisp:http-handler directive
mvn package # target/app-1.0.0.war, deployable with no further configuration
maven-war-plugin needs no configuration of its own: it copies target/classes into
WEB-INF/classes the way it copies any source set's output, and that is everything the
war needs. This goal writes the one file maven-war-plugin cannot generate on its own —
the jakarta.servlet.ServletContainerInitializer service declaration, the war's only
non-class file and the same one line every rontolisp war carries — so there is nothing
left to wire up: no web.xml, no program name, no init parameter. The
jakarta.servlet-api dependency (provided scope, 6.0.0 or newer) is the project's own,
declared the way every Servlet project declares it — never the plugin's, since it is
never bundled into anything rontolisp compiles.
Servlet mode ignores noMain. A war has no main to remove, so every file under
src/main/lisp compiles unconditionally instead of being gated on a rontolisp:jvm-export
the way the default library mode gates it — and each one must carry its own
rontolisp:http-handler directive (or the internal %http-server-start seam a
Clack application rides on), the same requirement -o app.war places on the
command line. Shared code belongs in a file the handler loads with (load ...), not in a
sibling .lisp file under the same source directory.
servlet and the project's packaging are checked against each other, and a mismatch
fails the build by name rather than producing a war that deploys and answers every
request with a 404: <servlet>true</servlet> without <packaging>war</packaging> names
the missing packaging, and <packaging>war</packaging> without <servlet>true</servlet>
names the missing parameter.
Packaging for Maven consumers
When the kernels are built separately from the projects that use them — a team
publishing to other teams, a non-Maven consumer, anything pushed to a repository
— the compiler writes the artifact itself. -o out.jar compiles straight to a
jar, and --maven-coordinates stamps that jar with its own identity:
rontolisp kernels.lisp -o acme-kernels-1.0.0.jar \
--class-name com.example.Kernels \
--maven-coordinates com.example:acme-kernels:1.0.0 \
--no-main
--class-name is not a convenience here — a .jar path names no class, and a
library's class IS the API its callers import, so --no-main requires the flag.
(A program jar, which is entered through the manifest, derives the name from its
file name instead.) It works for .class output too, where it replaces the name
the -o path would give, so a build directory no longer has to be shaped like
the package.
The coordinates then ride inside the jar, as the
META-INF/maven/<groupId>/<artifactId>/pom.xml + pom.properties pair every
Maven-built jar already carries. Installing it therefore takes no coordinate
flags at all — no -DgroupId, no -DartifactId, no -Dversion, no
-DpomFile:
mvn install:install-file -Dfile=acme-kernels-1.0.0.jar
and it is an ordinary dependency from there on:
<dependency>
<groupId>com.example</groupId>
<artifactId>acme-kernels</artifactId>
<version>1.0.0</version>
</dependency>
--emit-pom writes that same pom next to the jar as acme-kernels-1.0.0.pom
as well, for a deploy-file that wants it as a separate file; it refuses to
overwrite a pom it did not write itself.
What the jar contains
| entry | when |
|---|---|
META-INF/MANIFEST.MF | always — with a Main-Class only when the class has a main, so java -jar runs a program jar and a --no-main library jar carries none |
com/example/Kernels.class | always |
am/ik/rontolisp/runtime/*.class | when a :float-vector / :float-matrix export declares the handle type |
META-INF/maven/.../pom.xml, pom.properties | with --maven-coordinates |
The handle classes travel inside the artifact at their canonical names rather
than renamed into your package, because two rontolisp libraries have to agree
on the type for a caller to feed one's result to the other's kernel; the copies
are identical bytes. Forgetting them would not be a compile error here — it
would be a NoClassDefFoundError in the consumer.
The generated pom's <dependencies> is empty, and that is the point rather
than an omission: a compiled class embeds everything it calls, so the artifact
really has none. One acceleration note: a --simd build gets its vector
kernels only on a JVM started with --add-modules jdk.incubator.vector —
without the module the class degrades to the portable scalar kernels and says
so — and the generated pom repeats it in its <description>, because the
consumer never saw the build command. --blas and --gpu builds probe for
their native library at run time and degrade the same way, so they need nothing
from the consumer either.
Compiling the same program twice produces byte-identical jars: the entry order and the entry timestamps are fixed, not taken from the clock.
Limitations
- Only a fixed-arity top-level
defuncan be exported;&optional/&rest/&keylambda lists are refused (wrap them in a fixed-aritydefun). - A packed float array crosses at rank 1 or 2; rank 3 and above has no
designator yet, and a general (boxed) array has none at all —
:bytesis still the only designator for a non-float array. -o out.jarwrites exactly one artifact: no-sourcesor-javadocjar, and no signature.install-file/deploy-filetake those as their own flags.