In this post, we’re going to implement a small CLI utility for data pipelines over tabular data.
We’re going to get the JVM to turn a pipeline like seqgen --stop 1000000 --field A then put '$A = $A * 1024' then stats1 --field A --accumulators min,sum into this hot loop:
; unrolling disabled where possible for clarity ↗ vpslld zmm5, zmm4, 0xa ; A * 1024 1.16% │ vpminsd zmm0, zmm0, zmm5 ; min reduction47.89% │ vpaddd zmm4, zmm1, zmm4 ; A = seqgen counter induction variable 2.67% │ vpaddd zmm2, zmm2, zmm5 ; sum reduction44.88% │ lea ebx, [rbx + 0x10] │ cmp edi, ebx ╰ jg loopThere is a lot of prior art in the JVM ecosystem, like Trino and Spark. These systems approach the problem by generating JVM bytecode or Java code at runtime, which is a lot of work and not easy to write. I’ve seen early proposals to add explicit runtime specialization to the JVM, and earlier Valhalla discussion of how specialization might be expressed, but my crystal ball says that’s still a decade away.
My goal with this project is to get there with normal-looking Java code. No custom bytecode generation to implement the logic, no embedded Java compilers, and no switching between precompiled kernels. Finding closed-form solutions or doing complex execution plan analysis is also out of scope. The focus is on the quality of the generated code for this Zen 4 AVX-512 target machine I’m on. I’ll first establish the reification primitives that make this possible, then use them to build a general batch protocol. Runtime respecialization and whole-pipeline fusion come later as extensions. Fusing stages together is what reaches the exact loop presented above, but it also goes past the comfort zone and runs into serious obstacles.
Background and motivation
I’ve been using Miller a lot lately. It’s an all-in-one replacement for awk, sed, cut, join, and sort for tabular data. Here’s a quick taste:
$ mlr --csv \ # regex rename columns - from space to underscore rename -g -r ' ,_' then \ # rename a specific column rename 'predicted_render_time,prt' then \ # DSL for arbitrary transformations put ' # $ references specific columns $rt = $render_end - $render_start; $delta = $prt - $rt; ' then \ # univariate stats for each of the three columns listed stats1 -f prt,rt,delta -a p50,p75,p95 then \ # the DSL has many useful functions # this script formats each column's nanos into millis put 'for (k, v in $*) { $[k] = fmtnum(v / 1e6, "%.2f") . " ms" }' \ # input file comes last; don't ask me why "$HOME/kwin perf statistics HDMI-A-1.csv"
prt_p50,prt_p75,prt_p95,rt_p50,rt_p75,rt_p95,delta_p50,delta_p75,delta_p957.51 ms,9.72 ms,427.79 ms,2.07 ms,2.10 ms,2.23 ms,6.41 ms,8.31 ms,425.72 msYou build small data pipelines: usually by reading from an input file, then transforming it with various verbs along the way, and finally outputting the results in your preferred format. It’s simple to start with and rich in capability.
I contributed a minor thing to Miller recently, which exposed me to the internals. I started thinking about Miller’s dynamic nature: at its core, every invocation can use a drastically different pipeline. The set of operations can vary, and so can the input schema.
Miller supports this by using dynamic dispatch through Go’s interfaces:
type RecordTransformer interface { Transform( inrecAndContext *types.RecordAndContext, outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext inputDownstreamDoneChannel <-chan bool, outputDownstreamDoneChannel chan<- bool, )}Each record, or row of data, is itself a heterogeneous hash map with the data for each cell ultimately stored in an interface{}:
type Mlrval struct { printrep string intf interface{} err error // Payload for MT_ERROR types printrepValid bool // Enumeration for string / int / float / boolean / etc. // I would call this "type" not "mvtype" but "type" is a keyword in Go. mvtype MVType}And invoking the per-record transformer adds another indirect function call:
func runSingleTransformerBatch( inputRecordsAndContexts []*types.RecordAndContext, // list of types.RecordAndContext recordTransformer RecordTransformer, isFirstInChain bool, outputRecordChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext inputDownstreamDoneChannel <-chan bool, outputDownstreamDoneChannel chan<- bool, dataProcessingErrorChannel chan<- error, options *cli.TOptions,) (bool, error) { outputRecordsAndContexts := make([]*types.RecordAndContext, 0, len(inputRecordsAndContexts)) done := false
for _, inputRecordAndContext := range inputRecordsAndContexts {27 collapsed lines
// --nr-progress-mod // TODO: function-pointer this away to reduce instruction count in the // normal case which it isn't used at all. No need to test if {static thing} != 0 // on every record. if options.NRProgressMod != 0 { if isFirstInChain && inputRecordAndContext.Record != nil { context := &inputRecordAndContext.Context if context.NR%options.NRProgressMod == 0 { fmt.Fprintf(os.Stderr, "NR=%d FNR=%d FILENAME=%s\n", context.NR, context.FNR, context.FILENAME) } } }
// Three things can come through: // // * End-of-stream marker // * Non-nil records to be printed // * Strings to be printed from put/filter DSL print/dump/etc // statements. They are handled here rather than fmt.Println directly // in the put/filter handlers since we want all print statements and // record-output to be in the same goroutine, for deterministic // output ordering. // // The first two are passed to the transformer. The third we send along // the output channel without involving the record-transformer, since // there is no record to be transformed.
if inputRecordAndContext.EndOfStream || inputRecordAndContext.Record != nil {
err := recordTransformer.Transform( inputRecordAndContext, &outputRecordsAndContexts, inputDownstreamDoneChannel, outputDownstreamDoneChannel, )Records are passed over channels, in batches, and every transformer is a separate goroutine. Batch size is entirely dependent on individual transformers. For example, seqgen, which is a verb that generates an incrementing series, materializes all results in a single batch passed forward in a single channel element:
$ systemd-run --user --pty --wait --collect -p MemoryMax=20G -p MemorySwapMax=0 \ mlr \ seqgen -f counter --start 1 --stop 100000000 then \ stats1 -f counter -a minRunning as unit: run-p1264700-i1286487.servicePress ^] three times within 1s to disconnect TTY. Finished with result: oom-kill Main processes terminated with: code=killed, status=9/KILL Service runtime: 5.586ms CPU time consumed: 7.315s Memory peak: 20G (swap: 0B)Still, even if batch sizes were controlled, there’s no good way of predicting the size of each row. Knowing the schema ahead of time and planning batch sizes to have them fit in L3 cache (to maximize bandwidth in the downstream goroutine) would be worthwhile. Better yet, it would probably be faster to avoid the goroutine and stay in L1 cache by using smaller batches. This is usually called cache blocking, or tiling.
For a statically compiled program, it would be impossible to specialize the operations to all potential schemas the user might throw at it at runtime. Polars comes to mind and it does attempt exactly that by bundling many kernels and then dispatching to them at runtime, depending on the exact query plan.
# just a small benchmark wrapper to invoke Polars from the CLI and print its plan too$ POLARS_MAX_THREADS=1 ./polars '( pl.LazyFrame() .select(pl.int_range(0, 1000000, dtype=pl.Int32).alias("A")) .with_columns(A=pl.col("A") * 2) .min() )'
<~~ plan [in-memory] ~~>SELECT [col("A").min()] WITH_COLUMNS: [[(col("A")) * (2)]] SELECT [0.int_range([1000000]).alias("A")] DF []; PROJECT */0 COLUMNSbenchmark results over 100 runs (20 warmup)polars collect [engine=in-memory]: min 0.170 p25 0.172 p50 0.173 p75 0.178 max 0.532 mspolars collect [engine=streaming]: min 0.256 p25 0.260 p50 0.264 p75 0.269 max 0.357 ms
$ POLARS_MAX_THREADS=1 ./polars '( pl.LazyFrame() .select(pl.int_range(0, 1000000, dtype=pl.Int32).alias("A")) .with_columns(A=pl.col("A") * 2 * 3) .min() )'<~~ plan [in-memory] ~~>SELECT [col("A").min()] WITH_COLUMNS: [[([(col("A")) * (2)]) * (3)]] SELECT [0.int_range([1000000]).alias("A")] DF []; PROJECT */0 COLUMNSbenchmark results over 100 runs (20 warmup)polars collect [engine=in-memory]: min 0.228 p25 0.230 p50 0.232 p75 0.234 max 0.254 mspolars collect [engine=streaming]: min 0.284 p25 0.295 p50 0.302 p75 0.331 max 1.322 msA profiling run reveals key insights:
$ perf annotate --stdio --disassembler-style=intel --stdio-color always# ... 0.00 : 1d12610: vmovdqu ymm2,YMMWORD PTR [rdi+r8*4] 2.45 : 1d12616: vmovdqu ymm3,YMMWORD PTR [rdi+r8*4+0x20] 0.00 : 1d1261d: vmovdqu ymm4,YMMWORD PTR [rdi+r8*4+0x40] 5.30 : 1d12624: vmovdqu ymm5,YMMWORD PTR [rdi+r8*4+0x60] 0.00 : 1d1262b: vpsllvd ymm2,ymm2,ymm1 0.00 : 1d12630: vpsllvd ymm3,ymm3,ymm1 8.57 : 1d12635: vpsllvd ymm4,ymm4,ymm1 8.12 : 1d1263a: vpsllvd ymm5,ymm5,ymm1 2.58 : 1d1263f: vmovdqu YMMWORD PTR [rsi+r8*4],ymm2 6.34 : 1d12645: vmovdqu YMMWORD PTR [rsi+r8*4+0x20],ymm3 20.17 : 1d1264c: vmovdqu YMMWORD PTR [rsi+r8*4+0x40],ymm4 28.16 : 1d12653: vmovdqu YMMWORD PTR [rsi+r8*4+0x60],ymm5 17.15 : 1d1265a: add r8,0x20 1.15 : 1d1265e: cmp rax,r8 0.00 : 1d12661: jne 1d12610 <polars_compute::arity::ptr_apply_unary_kernel::<i32, i32, <i32 as polars_compute::arithmetic::PrimitiveArithmeticKernelImpl>::prim_wrapping_mul_scalar::{closure#0}>+0x50>- Polars is compiled for AVX2 here and doesn’t bundle a wider kernel, so it can’t make the best use of my CPU.
- In this run, the input and output pointers differ, so the kernel copies to a new location instead of transforming values in place.
- It is manually specialized by the Polars developers to detect a power-of-two scalar and strength-reduce the multiplication into a left shift. That’s pretty impressive, but they don’t decompose code like
x * 5into(x << 2) + x. Your production compiler would do that for static code. - Polars launches two mult kernels back to back and doesn’t fuse operations.
- Each kernel seems to operate on entire columns at a time — no tiling is attempted to stay in CPU caches and reuse operands between back-to-back kernels.
Reification primitives
Before we can implement our own data pipeline, we need to figure out how to hit the JIT fast paths.
Since the processing is controlled through runtime CLI arguments, we need to take care to assemble the computation so it optimizes as well as static code.
Let’s start small by building a very simple interpreter.
Something that will eventually implement a DSL similar to what Miller supports in put "$out = $in * 5".
@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)@OperationsPerInvocation(NaiveInterpreterBenchmark.ROWS)public class NaiveInterpreterBenchmark { static final int ROWS = 1024;
@State(Scope.Benchmark) public static class BenchState { int[] data = IntStream.range(0, ROWS).toArray(); Expr interpreter = new ExprAdd(new ExprConst(2), new ExprRead()); }
// static, handwritten implementation: @Benchmark public void direct(BenchState state) { final int[] data = state.data; for (int i = 0; i < data.length; i++) { data[i] = data[i] + 2; } }
// data-driven interpreter: public static interface Expr { public int eval(int input); }
public static final record ExprConst(int val) implements Expr { @Override public int eval(int input) { return val; } }
public static final record ExprAdd(Expr lhs, Expr rhs) implements Expr { @Override public int eval(int input) { return lhs.eval(input) + rhs.eval(input); } }
public static final record ExprRead() implements Expr { @Override public int eval(int input) { return input; } }
@Benchmark public void interpreted(BenchState state) { final int[] data = state.data; final var interpreter = state.interpreter; for (int i = 0; i < data.length; i++) { data[i] = interpreter.eval(data[i]); } }}Both of these optimize to the same hot code on OpenJDK 26:
Benchmark Mode Cnt Score Error UnitsNaiveInterpreterBenchmark.direct avgt 9 0.025 ± 0.001 ns/opNaiveInterpreterBenchmark.interpreted avgt 9 0.025 ± 0.001 ns/op ↗ mov ecx, edx │ vpaddd zmm1, zmm0, zmmword ptr [rsi + rcx*4 + 0xc] │ vmovdqu32 zmmword ptr [rsi + rcx*4 + 0xc], zmm1 18.69% │ vpaddd zmm1, zmm0, zmmword ptr [rsi + rcx*4 + 0x4c] 0.02% │ vmovdqu32 zmmword ptr [rsi + rcx*4 + 0x4c], zmm1 23.28% │ vpaddd zmm1, zmm0, zmmword ptr [rsi + rcx*4 + 0x8c] │ vmovdqu32 zmmword ptr [rsi + rcx*4 + 0x8c], zmm1 20.86% │ vpaddd zmm1, zmm0, zmmword ptr [rsi + rcx*4 + 0xcc] │ vmovdqu32 zmmword ptr [rsi + rcx*4 + 0xcc], zmm1 20.94% │ lea edx, [rcx + 0x40] │ cmp edx, r11d ╰ jl loopLet’s try something just a bit more complicated:
public class NaiveInterpreterBenchmark { public static class BenchState { // ...
Expr interpreter2 = new ExprAdd( new ExprConst(2), new ExprAdd(new ExprConst(3), new ExprRead()) ); }
@Benchmark public void direct2(BenchState state) { final int[] data = state.data; for (int i = 0; i < data.length; i++) { data[i] = data[i] + 2 + 3; } }
@Benchmark public void interpreted2(BenchState state) {5 collapsed lines
final int[] data = state.data; final var interpreter = state.interpreter2; for (int i = 0; i < data.length; i++) { data[i] = interpreter.eval(data[i]); } }}Benchmark Mode Cnt Score Error UnitsNaiveInterpreterBenchmark.direct2 avgt 9 0.025 ± 0.001 ns/opNaiveInterpreterBenchmark.interpreted2 avgt 9 1.211 ± 0.037 ns/opThe interpreter performance immediately falls off a cliff!
Disassembly shows that vectorization is no longer present, and the scalar code is full of branches for type checks and other nastiness.
I’ll spare you from having to look at it.
Instead, let’s see the output of -XX:CompileCommand="print *ExprAdd::eval":
dev.miller4j.NaiveInterpreterBenchmark$ExprAdd::eval(I)I interpreter_invocation_count: 695088 invocation_counter: 695088 backedge_counter: 0 decompile_count: 0 mdo size: 560 bytes
0 fast_aaccess_0 1 fast_agetfield 7 <dev/miller4j/NaiveInterpreterBenchmark$ExprAdd.lhs:Ldev/miller4j/NaiveInterpreterBenchmark$Expr;> 4 iload_1 5 invokeinterface 16 <dev/miller4j/NaiveInterpreterBenchmark$Expr.eval(I)I> 0 bci: 5 VirtualCallData count(0) entries(1) 'dev/miller4j/NaiveInterpreterBenchmark$ExprConst'(694577 1.00) 10 fast_aaccess_0 11 fast_agetfield 13 <dev/miller4j/NaiveInterpreterBenchmark$ExprAdd.rhs:Ldev/miller4j/NaiveInterpreterBenchmark$Expr;> 14 iload_1 15 invokeinterface 16 <dev/miller4j/NaiveInterpreterBenchmark$Expr.eval(I)I> 144 bci: 15 VirtualCallData count(0) entries(2) 'dev/miller4j/NaiveInterpreterBenchmark$ExprRead'(347289 0.50) 'dev/miller4j/NaiveInterpreterBenchmark$ExprAdd'(347288 0.50) 20 iadd 21 ireturnProfiling data for ExprAdd::eval confirms that rhs.eval gets called with two different receivers — the call site is bimorphic, with dispatch split evenly between ExprAdd::eval and ExprRead::eval.
This doesn’t necessarily prevent inlining, but introducing control flow prevents the loop from being vectorized.
We can recover the lost performance by storing the interpreter in a static final field:
public static class BenchState { // ...
Expr interpreter2 = new ExprAdd( new ExprConst(2), new ExprAdd(new ExprConst(3), new ExprRead()) );
static final Expr interpreter3 = new ExprAdd(new ExprConst(2), new ExprAdd(new ExprConst(3), new ExprRead())); }
@Benchmark public void interpreted3(BenchState state) {5 collapsed lines
final int[] data = state.data; final var interpreter = BenchState.interpreter3; for (int i = 0; i < data.length; i++) { data[i] = interpreter.eval(data[i]); } }Although the call site has the same VirtualCallData as before, the loop vectorizes again to reach peak performance:
Benchmark Mode Cnt Score Error UnitsNaiveInterpreterBenchmark.interpreted3 avgt 9 0.025 ± 0.001 ns/opWhen the receivers are provably constant — and they are when we store a tree of immutable Records in a static final field — the compiler can fully inline and devirtualize the call graph. There is just one caveat in that optimization decisions are specific to a call site at the bytecode level. So, when we complicate the example a bit more:
public static class BenchState { // ...
// unmodifiable List, with immutability enforced and understood by the VM static final List<Expr> interpreters = List.of( new ExprAdd(new ExprConst(2), new ExprAdd(new ExprConst(3), new ExprRead())), // same result, flipped operands: new ExprAdd(new ExprAdd(new ExprConst(3), new ExprRead()), new ExprConst(2)) ); }
@Benchmark public void twoInterpreters(BenchState state) { final int[] data = state.data; final var interpreters = BenchState.interpreters; for (int i = 0; i < data.length; i++) { for (int j = 0; j < interpreters.size(); j++) {
data[i] = interpreters.get(j).eval(data[i]); } } }
@Benchmark public void twoInterpretersSplit(BenchState state) { // ... for (int j = 0; j < interpreters.size(); j++) {
if (j == 0) data[i] = interpreters.get(j).eval(data[i]); if (j == 1) // for reasons unknown, this cannot be an else if data[i] = interpreters.get(j).eval(data[i]); } }Both list elements have the same receiver class, ExprAdd, so the outer call site in @Benchmark twoInterpreters is monomorphic.
The polymorphism appears deeper, in ExprAdd::eval — its lhs.eval and rhs.eval call sites see different receiver classes.
Splitting moves each call into the constant-receiver regime, where inlining follows the object graph instead of the profile, and a profile only records receiver classes, so it can never tell our two ExprAdd instances apart.
Our split call site variant still routes to a specific, constant object graph and the compiler can inline through it:
Benchmark Mode Cnt Score Error UnitsNaiveInterpreterBenchmark.twoInterpreters avgt 9 9.975 ± 0.918 ns/opNaiveInterpreterBenchmark.twoInterpretersSplit avgt 9 0.025 ± 0.001 ns/opNote
If we changed ExprAdd from a record to a class with final fields, it wouldn’t optimize as well.
Records are special this way and so is half the stdlib, apparently.
If I understand correctly, this divergence will disappear when JEP “Integrity by Default” lands.
Until then, we need to pay more attention if we want to hit this fast path.
The takeaway is that invoking interface methods such as Expr::eval is acceptable, so long as the receiver is constant enough for the VM.
That means starting from a static final field and tracing the receiver all the way through immutable fields.
static final dynamic
All the examples we’ve seen so far used static code.
The exact data structure or code had to have been written directly or produced in a class initializer and written to a static final field.
This is obviously not enough to optimize the enormous surface area formed by CLI arguments for the real mlr command.
We can’t jump this hoop with Java-the-language, but Java-the-virtual-machine can load new code at runtime, and that code can contain static finals.
It’s not much different from how our compiled .java code gets loaded.
To close the loop, we need to generate a class file at runtime and load it into the VM instance we are running on.
I explicitly did not want to hand-write bytecode for pipeline stages.
Instead, an annotation processor turns normal Java functions into reification factories, in a generic way: annotating a method in Foo gets you a companion Foo_Reified holding a factory for it.
At runtime, those factories use Cojen Maker to generate thin binding classes: they hoist runtime values into static final fields and materialize fresh call sites that invoke my existing Java methods.
No logic is ever emitted as bytecode; it stays in the Java I wrote.
public class NaiveInterpreterBenchmark {
@Reified // @Constant arguments become `static final` fields in the generated class static int reifyExpr(@Constant Expr expr, int input) { return expr.eval(input); }
public static class BenchState { // from earlier Expr interpreter2 = new ExprAdd( new ExprConst(2), new ExprAdd(new ExprConst(3), new ExprRead()) );
// This generates and loads a new class into the current JVM, giving us an // instance handle that implements the interface. NaiveInterpreterBenchmark_Reified.ReifyExpr interpreter2Reified = // @Constant arguments are passed in at creation time: NaiveInterpreterBenchmark_Reified.reifyExpr(interpreter2); }
// ...
@Benchmark public void reified2(BenchState state) { final int[] data = state.data; final var interpreter = state.interpreter2Reified; for (int i = 0; i < data.length; i++) { // only non-@Constant arguments are supplied in the invocation data[i] = interpreter.apply(data[i]); } }}One of those runtime binding classes decompiles to this:
public final class GeneratedReifiedStatic44 implements NaiveInterpreterBenchmark_Reified.ReifyExpr { private static final NaiveInterpreterBenchmark.Expr BOUND86 = (NaiveInterpreterBenchmark.Expr) Specializers.boundReference(86);
@Override public int apply(int n) { // The annotation processor also generates a companion CompileCommand directive. // We nudge the compiler to inline this function and the call inside so it can // reliably fold the constant. // // inline dev.miller4j.NaiveInterpreterBenchmark::reifyExpr // inline dev.miller4j.GeneratedReifiedStatic*::apply return NaiveInterpreterBenchmark.reifyExpr(BOUND86, n); }}The generated bytecode only binds constants and creates the call site.
This reification approach is different from monomorphization in static languages — we’re only duplicating the glue that calls our shared functions.
Of course, once these functions get optimized to native code, we end up with a similar result: every instance is specialized and optimized independently.
The main logic stays in reifyExpr, typechecks with the rest of the program, and therefore feels like ordinary Java.
There’s not a separate stage of compilation that would defer errors until runtime.
We’re not generating complicated blobs of bytecode and depending on the JVM’s verifier to surface issues at load time.
Code from the new class can link to (read: use) anything else that we’ve already loaded in. To pass data into the new class, we make it reference a simple global registry that stores arbitrary objects:
static final AtomicInteger REFERENCE_IDS = new AtomicInteger();static final Object NULL_REFERENCE = new Object();static final ConcurrentMap<Integer, Object> REFERENCES = new ConcurrentHashMap<>();
static int registerReference(@Nullable Object value) { final int id = REFERENCE_IDS.incrementAndGet(); REFERENCES.put(id, value == null ? NULL_REFERENCE : value); return id;}
static @Nullable Object boundReference(int id) { final Object value = REFERENCES.remove(id); if (value == null) throw new IllegalStateException("Missing bound reference: " + id); return value == NULL_REFERENCE ? null : value;}
// later, when generating the binding class with Cojen Maker:classMaker.addField(type, name).private_().static_().final_();final var clinit = classMaker.addClinit();clinit .field(name) .set(clinit.var(Specializers.class).invoke("boundReference", referenceId).cast(type));This approach regains all of the performance without requiring a source-level static final field for every runtime expression:
Benchmark Mode Cnt Score Error UnitsNaiveInterpreterBenchmark.interpreted2 avgt 9 1.227 ± 0.010 ns/opNaiveInterpreterBenchmark.reified2 avgt 9 0.025 ± 0.001 ns/opWe’ll have plenty of @Constant candidates just in the DSL script alone, but generally speaking, every parameter from CLI verbs makes for great initial candidate.
There are --start and --stop from the seqgen verb, and -n from head and tail.
When we analyze the schema along the entire pipeline of verbs, that’s worth making constant too.
We’ll even inline pointers to our memory segments into the instruction stream.
But we have some more primitives to walk through first before we get ahead of ourselves.
Loops and Object[] registerFiles
We’ve looked at the importance of separating call sites, keeping profiling data isolated, and ideally make the entire call tree go through static final receivers.
Composing computations into a bigger hierarchy with @Reified enables us to specialize what otherwise would be branches or dynamic dispatch into straight-line code instead.
Let’s now do the same for loops, which are the other source of shared call sites that we need a separate solution for.
Speaking of composition, we need a good way to share state between the different functions we reify. Ideally, we’d store our data in local variables — specifically primitive types whenever possible — but since we’re not emitting Java code nor bytecode functions, this is not an option for us. JVM functions can only return up to one value, and anything that is passed as an argument gets copied. So how can we represent pipeline state and all the data for every column in the schema to be able to pass that between miller verbs?
The answer I came up with is using an Object[] registerFile on the semantic level while ensuring its elements get scalar-replaced by the compiler.
Safe Harbor from Oracle® — a Statement
No company code was ever executed on Oracle® GraalVM™. It was never installed on any company-owned hardware. I only used it with my own code, on my own time, and on my personal computer.
This is only possible with the Graal JIT compiler and not C2, so the rest of the post will use that unless noted otherwise. There are a couple basic requirements to make scalar-replacement reliable:
- Use constant indices with this array. We’ll often use
@Constant int slotIxcoupled withregisterFile[slotIx] = .... - Never mix or confuse types of a given slot. The patterns that work well are:
(T) registerFile[ix]to read,registerFile[ix] = (T) valueto store. This way, we can imagine having an@Reifiedfunction which simultaneously accepts@Constant int myStorageIndexand theObject[]state. - Initialize all the slots you need at the start, before first use.
- (situational) Zero-out slots after last use — this will become relevant much later.
To show this off, let’s construct something that looks a bit more like the stats1 function from Miller.
It’ll need to store the state of its accumulators calculate the requested reductions like min, max, sum, for every row of data it ingests.
@OperationsPerInvocation(4096)public class PipelineBenchmark {
@FunctionalInterface interface Processor { void apply(Object[] registerFile, int value); }
// `extending` selects a specific interface instead of an auto-generated one @Reified(extending = Processor.class) public static void process( @Constant int registerIx, @Constant String reduction, Object[] registerFile, int value) { int accumulator = (int) registerFile[registerIx]; accumulator = switch (reduction) { case "min" -> Math.min(accumulator, value); case "max" -> Math.max(accumulator, value); case "sum" -> accumulator + value; default -> throw new UnsupportedOperationException("unknown reduction"); }; registerFile[registerIx] = accumulator; }
// This annotation is a little more involved: it accepts a list of operations // and unrolls to give each operation its own call site. // Runtime arguments are broadcast to each step and we use Object[] for its // internal mutability. // // Returns a SAM interface implementation with an `invoke` method accepting // the runtime arguments. @ReifiedLoop(step = Processor.class) static void pipelineSum(Object[] registerFile, int value) {}
@State(Scope.Benchmark) public static class BenchState { int[] data = IntStream.range(0, 4096).toArray();
List<Processor> reducers = List.of( PipelineBenchmark_Reified.process(0, "sum"), PipelineBenchmark_Reified.process(1, "min"), PipelineBenchmark_Reified.process(2, "max") );
// Lambdas are more concise and can optimize just as well, but depend on // normal inlining heuristics. @Reified produces force-inlined call sites // that bypass these limits. PipelineBenchmark_Reified.PipelineSum pipelineReified = PipelineBenchmark_Reified.pipelineSum(reducers); }
@Benchmark public long handwritten(BenchState state) {10 collapsed lines
final var arr = state.data; int sum = 0; int min = Integer.MAX_VALUE; int max = Integer.MIN_VALUE; for (final var el : arr) { sum = sum + el; min = Math.min(min, el); max = Math.max(max, el); } return sum + min + max; }
@Benchmark public long reified(BenchState state) { final var arr = state.data; final var pipeline = state.pipelineReified; // initial state for each accumulator: sum, min, max Object[] registerFile = new Object[] {0, Integer.MAX_VALUE, Integer.MIN_VALUE}; for (final var el : arr) { pipeline.invoke(registerFile, el); } return (int) registerFile[0] + (int) registerFile[1] + (int) registerFile[2]; }}Benchmark Mode Cnt Score Error UnitsPipelineBenchmark.handwritten avgt 9 0.462 ± 0.005 ns/opPipelineBenchmark.reified avgt 9 0.461 ± 0.002 ns/opBenchmarked without autovectorization
Graal is unable to autovectorize our reified loop after scalarizing it, for whatever reason.
This is not a big deal; we will be using the explicit vector API in the real implementation, and storing IntVectors inside of Object[] works out fine.
This is just another performance cliff to avoid for now, and it’s far from the only one related to Object[], but more on that later.
For now, disabling vectorization makes it easier to compare the optimization.
The exact flags used: -Djdk.graal.VectorizeLoops=false -Djdk.graal.FullUnroll=false -Djdk.graal.PartialUnroll=false
The assembly output is equivalent for the two implementations and looks like this:
mov eax, edxmov edi, r10dmov edx, r14dadd edx, r13dmov edx, dword ptr [r8 + rdx*4 + 0xc] ; load elementadd ebp, edx ; sum reducercmp edx, edimov r10d, edxcmovle r10d, edi ; max reducercmp edx, eaxcmovge edx, eax ; min reducerinc r14dcmp r11d, r14djg loop ; back to topNotably, switch (reduction) and other operations on @Constant values have been partially evaluated when the function was compiled.
The Object[] and its boxed accumulators are gone as well — all three accumulators live in registers for the whole loop.
This is a tough register file. One of the most allocation-free we’ve ever seen from the standpoint of heap memory.
The generated class decompiles to this:
public final class GeneratedReifiedLoop4implements PipelineBenchmark_Reified.PipelineSum { private static final PipelineBenchmark.Processor BOUND1 = (PipelineBenchmark.Processor)Specializers.boundReference((int)1); private static final PipelineBenchmark.Processor BOUND2 = (PipelineBenchmark.Processor)Specializers.boundReference((int)2); private static final PipelineBenchmark.Processor BOUND3 = (PipelineBenchmark.Processor)Specializers.boundReference((int)3);
public void invoke(Object[] objectArray, int n) { BOUND1.apply(objectArray, n); BOUND2.apply(objectArray, n); BOUND3.apply(objectArray, n); }}Each step becomes its own static final field and gets a dedicated call site, allowing for peak optimization.
The annotation processor emits method-specific CompileCommand directives.
Together with a handful of static rules, we get:
inline dev.miller4j.GeneratedReifiedLoop*::invokeinline dev.miller4j.PipelineBenchmark::pipelineSumForced inlining is critical to get scalar-replacement of Object[].
If we miss a single function, or enter a safepoint poll, or throw an exception when this object is still live, the compiler may bail out of the optimization.
Implementing the Miller pipeline
We’re now ready to implement the pipeline abstraction that assembles reified steps from CLI arguments and executes them end-to-end.
To recap the driving principle: reification lets us lift the point of dynamic dispatch higher in the call tree, and keep everything below that statically dispatched.
This unlocks inlining and scalarization of values we pass around, which in turn unlocks a whole host of further optimizations.
Reification will be used to generate an executor loop with separate call sites for each stage of the pipeline, with these being maximally specialized with @Constant values read from the CLI or otherwise derived during planning.
We’re only implementing a small subset of Miller verbs, and skipping file inputs for now.
The overall architecture looks something like this:
- Pipeline stages are assembled from CLI arguments,
- A planning pre-pass calls into each stage to perform schema evolution and gathers required allocations from each stage.
Each stage can express:
- what columns it wants to add/drop from the schema
- what private scratch data it needs to allocate — it will be preserved for the entire pipeline run
- The final allocation and batch size are calculated,
- Every stage gets reified into an instance of
BatchOp, with its configuration, schema, and memory allocations available as@Constants, - A list of
BatchOpsgets reified into a driver loop, - The driver executes in a loop, with batches of rows passed forward between stages,
- Execution finishes when the last stage returns an EOF marker.
The iteration protocol is batch-oriented. Every stage can receive a small number of rows (on the order of hundreds to a couple thousand), transform them, and emit the same or a different number of rows to a downstream step. The control value describes row count of the batch and an EOF bit — I did not want to rely on scalarization of a compound object, so this is a bit-packed integer. When a stage reports EOF early, the pipeline runner will skip past that stage in its main loop. The run ends after the last stage reports completion.
This is the public API I came up with for each stage to plug into:
interface PipelineStage { void plan(Planner planner, Schema inputSchema);
BatchOp compileBatchOp( // Schema snapshot of the upstream step BatchLayout in, // Schema snapshot of what this step produces BatchLayout out, // Memory used to pass the input & output data for a batch MemorySegment data, // Scratch memory that persists throughout the full pipeline run MemorySegment scratch );
// Reset mutable state kept by a compiled source stage default void resetCompiledBatch() {}
// Initialize scratch memory, if needed default void resetScratch(BatchLayout layout, MemorySegment scratch) {}}
@FunctionalInterfaceinterface BatchOp { // The control word encodes the number of rows being passed in a batch, // as well as bit flags like EOF. int apply(int control);}
final class Planner { void addColumn(String name, ColumnType ty); void addScratch(String name, MemoryLayout layout);
// Sets an optional hint which helps the planner's automatic sizing void setOutputRowCapacity(int maxRowCount);
// Meant for transformations that can't be done in place: repoints a column // at freshly allocated storage so the prior (input) buffer and the new // (output) buffer don't alias. The previous allocation gets queued for // deallocation after this step. void dupStorage(String name);
void dropColumn(String name);
// Drops all columns to start from a clean slate. void reset();}
final record BatchLayout(...) { int maxRowCount();
// Returns a slice of the `base` segment for the given column MemorySegment resolveColumn(MemorySegment base, String name);
// Returns a slice of the `base` segment for the given scratch allocation MemorySegment resolveScratch(MemorySegment base, String name);}Generating batches with seqgen
seqgen is the pipeline’s source. It creates batches containing one integer column, filled with consecutive values from start through stop.
A simplified implementation of seqgen --start 1 --stop 500000000 looks like this:
final class SeqGen implements PipelineStage {16 collapsed lines
private static final VectorSpecies<Integer> SPECIES = IntVector.SPECIES_PREFERRED; private static final IntVector LANE_OFFSETS = IntVector.zero(SPECIES).addIndex(1);
final String name; final int start; final int stop; int current;
SeqGen(String name, int start, int stop) { if (start > stop) throw new IllegalArgumentException("start must not exceed stop"); this.name = name; this.start = start; this.stop = stop; this.current = start; }
@Override public void plan(Planner planner, Schema ignoredInput) { // This is a source: discard the input schema and create its output column. planner.reset(); planner.addColumn(name, new ColumnInt()); }
@Override public BatchOp compileBatchOp( BatchLayout in, BatchLayout out, MemorySegment data, MemorySegment scratch) { // Resolve the column once, while constructing this specialized operator. final var output = out.resolveColumn(data, name); return SeqGen_Reified.batchLocal(stop, out.maxRowCount(), output, this); }
@Reified(extending = BatchOp.class) static int batchLocal( @Constant int stop, @Constant int capacity, @Constant MemorySegment output, @Constant SeqGen state, int ignoredControl) { final int current = state.current; final int count = Math.min(Math.max(0, stop - current + 1), capacity);
// Build [current, current + 1, ...] once, then advance a whole vector at a time. var values = LANE_OFFSETS.add(current); final int fullCount = SPECIES.loopBound(count); int i = 0; for (; i < fullCount; i += SPECIES.length()) { values.intoMemorySegment( output, (long) i * ValueLayout.JAVA_INT.byteSize(), ByteOrder.nativeOrder()); values = values.add(SPECIES.length()); }
// The final batch can have a partial vector. if (i < count) { final var mask = SPECIES.indexInRange(i, count); values.intoMemorySegment( output, (long) i * ValueLayout.JAVA_INT.byteSize(), ByteOrder.nativeOrder(), mask); }
state.current += count; return control(
state.current > stop,
count ); }
@Override public void resetCompiledBatch() { current = start; }}The SeqGen reference is constant in the compiled operator, but its current field remains mutable between calls.
In a real pipeline, the full-vector hot path compiles to the following code:
movabs r11, 0x790ad5000000 ; output column's native addressmovabs r8, 0x711b15c00 ; output MemorySegment's sessionmovabs r9, 0x7118a2140 ; this SeqGen instancevmovdqu32 zmm0, [rip-0x113] ; constant [16, 17, ... 31]vmovdqu32 zmm1, [rip-0xdd] ; constant [0, 1, ... 15]vmovdqu32 zmm2, [rip-0xc5] ; sixteen copies of 16; bounds checks omittedvpbroadcastd zmm3, edx ; [current, current, ...]vpaddd zmm4, zmm3, zmm1 ; [current, current + 1, ...]
test eax, 0xfffffff0je partial_vector_path ; code omitted
; MemorySegment liveness check hoisted before the loop; as of this writing, needs a GraalVM EA build that includes this fix:; https://github.com/oracle/graal/pull/13917mov r13d, dword ptr [r8+8]test r13d, r13djl closed_session
; first iteration got peeledvmovdqu32 [r11], zmm4vpaddd zmm3, zmm3, zmm0 ; [current + 16, ... current + 31]mov ecx, 0x10
vector_loop:vmovdqu32 [r11+rcx*4], zmm3vpaddd zmm3, zmm3, zmm2lea ecx, [rcx+0x10]cmp ebp, ecxja vector_loop
mov ecx, eaxadd ecx, edxmov dword ptr [r9+0x10], ecx ; state.current += count; control word packing omittedOn my AVX-512 CPU, each loop iteration emits 16 integers with one 64-byte store and advances all lanes with one vpaddd.
The MemorySegment addresses, SeqGen object reference, and configuration parameters like --stop all became immediate values in the compiled instruction stream.
Graal hoisted both the spatial bounds check and the temporal MemorySegment liveness check out of the full-vector loop.
The hot loop itself is as good as it gets: just the store, vector addition, index increment, comparison and branch.
Scripting
For a more dynamic stage that leans even more heavily on reification, let’s look at the scripting as implemented for the put verb.
It’s again just a small subset of what Miller offers: we have basic arithmetic, static column references and a ternary operator.
No dynamic indexing, control flow, or built-in functions for now.
The implementation makes use of a small, additional reification helper - @Derived.
When the function is reified, the builder method referred to by this annotation will be invoked, and its result will be stored the same way an @Constant value is stored.
This lets us structure the tree of reified functions in a top-down way.
Without @Derived, we’d have to build up the computations in a bottom-up fashion and then pass them to the final reified function that invokes them.
The builder method specified in @Derived("builder") can accept any subset of @Constant arguments, matched by name, that the parent function receives.
This method is usually itself marked @Reified, or is a plain static function that assembles loop operations and calls a @ReifiedLoop generator function.
I find that structuring the reification tree in a top-down way makes it much more composable.
final class Script implements PipelineStage { @Override public BatchOp compileBatchOp( BatchLayout in, BatchLayout out, MemorySegment data, MemorySegment scratch) { final var evaluator = Script_Reified.batchEvaluator(this, out, data); return evaluator::apply; }
@Reified static int batchEvaluator( @Constant Script step, @Constant BatchLayout out, @Constant MemorySegment data, @Derived("buildStatementLoop") StatementLoop statements, int control) { final var rowCount = controlRowCount(control); for (var i = 0; i < rowCount; i++) { statements.invoke(i); } return control(controlOutputComplete(control), rowCount); }
@Reified(extending = StatementEvaluator.class) static void statementEvaluator( @Constant BatchLayout out, @Constant MemorySegment data, @Constant Scripting.Expr e, @Derived("compileEvaluator") Evaluator eval, @Constant MemorySegment dataRef, int rowIx) { dataRef.set(ValueLayout.JAVA_INT, (long) rowIx * Integer.BYTES, eval.apply(rowIx)); }
@ReifiedLoop(step = StatementEvaluator.class) static void statementLoop(int rowIx) {}}compileEvaluator, the key @Derived builder method, recursively matches on the expression AST.
Each branch invokes another generated reification factory, so every expression node becomes a reified Evaluator.
Here are a few interesting cases:
@Reified(extending = Evaluator.class)static int constEvaluator(@Constant int value, int rowIx) { return value;}
@Reified(extending = Evaluator.class)static int fetchEvaluator(@Constant MemorySegment column, int rowIx) { return column.get(ValueLayout.JAVA_INT, (long) rowIx * Integer.BYTES);}
@Reified(extending = Evaluator.class)static int binaryEvaluator( @Constant Evaluator left, @Constant Evaluator right, @Constant String operator, int rowIx) { return switch (operator) { case "*" -> left.apply(rowIx) * right.apply(rowIx); // ... default -> throw new UnsupportedOperationException(operator); };}
@Reified(extending = Evaluator.class)static int ternaryEvaluator( @Constant Evaluator condition, @Constant Evaluator ifTrue, @Constant Evaluator ifFalse, int rowIx) { return condition.apply(rowIx) != 0 ? ifTrue.apply(rowIx) : ifFalse.apply(rowIx);}
static Evaluator compileEvaluator( BatchLayout out, MemorySegment data, Scripting.Expr e) { return switch (e.kind()) { case Scripting.Kind.NUMBER -> Script_Reified.constEvaluator((int) e.number());
case Scripting.Kind.COLUMN -> Script_Reified.fetchEvaluator(out.resolveColumn(data, e.text()));
case Scripting.Kind.BINARY -> { final var left = compileEvaluator(out, data, e.operands().get(0)); final var right = compileEvaluator(out, data, e.operands().get(1)); yield Script_Reified.binaryEvaluator(left, right, e.text()); }
case Scripting.Kind.TERNARY -> { final var condition = compileEvaluator(out, data, e.operands().get(0)); final var ifTrue = compileEvaluator(out, data, e.operands().get(1)); final var ifFalse = compileEvaluator(out, data, e.operands().get(2)); yield Script_Reified.ternaryEvaluator(condition, ifTrue, ifFalse); }
// ... };}This is a little excessive in the number of @Reified functions used, and one could use lambdas for leaf computations, but I didn’t want to rely on Graal’s inlining heuristics that are bound to exhaust at some point.
Each assignment becomes its own reified statement: evaluate the RHS, then write the result to the destination column. We utterly rely on Graal to optimize this stage. Autovectorization turns our naive scalar code that operates on individual rows into a faster SIMD implementation. The compiler also deduplicates our memory accesses by caching repeated reads and only letting the last write in sequence to the same location survive.
A script like $B = $A * 16; $C = $B * 3 is therefore two vectorized statement evaluators that avoid round-tripping through memory:
vmovdqu32 zmm0, zmmword ptr [r11 + r13*4] ; read 16 values from $Avpslld zmm1, zmm0, 5 ; $A * 32vpslld zmm0, zmm0, 4 ; $B = $A * 16vpaddd zmm1, zmm0, zmm1 ; $Cvmovdqu32 zmmword ptr [rdx + r13*4], zmm0 ; store $Bvmovdqu32 zmmword ptr [r10 + r13*4], zmm1 ; store $CThe $B read in the second statement disappears completely: Graal strength-reduces $C = $B * 3 into ($A << 4) + ($A << 5).
It’s faster to compute $B * 3 this way than to compute ($B << 1) + $B.
By rebasing the computation on $A, it produces shorter dependency chains and more instruction-level parallelism.
Overall, this example comes closest to the stated goal introduced at the start of the post.
We get to write straightforward code that, as long as it’s reified appropriately, generates optimal code by taking advantage of the existing JIT compiler.
There is one small issue that I’m hoping gets addressed — oracle/graal#13958.
When Graal improves aliasing analysis on MemorySegments, this should eliminate some column reloads that follow earlier stores to another column, but this only shows up in longer scripts.
Reductions
stats1 is a verb that reduces incoming values and only outputs the results once the upstream stage fully completes.
Here’s a quick example of it:
$ mlr --tsv seqgen -f A --start 10 --stop 30 then stats1 -f A -a min,sumA_min A_sum10 420We implement it with reified loops over the requested fields and accumulators.
Each accumulator occupies one slot of an Object[].
StatGrid is the field × accumulator grid representing the configuration parameters.
Numeric semantics
These examples intentionally use wrapping 32-bit integer arithmetic for simplicity. Miller promotes overflowing integer results to floating point instead of silently wrapping, which is a useful policy for the end-user. My toy implementation focuses on proving out the codegen, so I did not replicate the same semantics.
@Reified(extending = BatchOp.class)static int kernel( @Constant StatGrid grid, @Constant BatchLayout in, @Constant BatchLayout out, @Constant MemorySegment data, @Constant MemorySegment scratch,
// each of these generates a @ReifiedLoop over a varying number of steps, // all depending on StatGrid configuration @Derived("buildInit") Stats1_Reified.InitLoop init, @Derived("buildChunk") Stats1_Reified.ChunkLoop chunk, @Derived("buildMaskedChunk") Stats1_Reified.MaskedChunkLoop maskedChunk, @Derived("buildFlush") Stats1_Reified.FlushLoop flush,
int control) { final boolean eof = controlOutputComplete(control); final int rowCount = controlRowCount(control); final var species = IntVector.SPECIES_PREFERRED; final int lanes = species.length(); final int fullCount = species.loopBound(rowCount); final Object[] state = new Object[grid.slots()];
init.invoke(state); for (int r = 0; r < fullCount; r += lanes) {
chunk.invoke(state, r); }
if (fullCount < rowCount) { maskedChunk.invoke(state, fullCount, species.indexInRange(fullCount, rowCount)); }
flush.invoke(state, control); return control(eof, eof ? 1 : 0);}
// one slot of the chunk.invoke() loop seen above@Reifiedstatic void chunkStep( @Constant int slot, @Constant VectorOperators.Associative reducer, @Constant MemorySegment input, Object[] state, int row) { final var values = IntVector.fromMemorySegment( IntVector.SPECIES_PREFERRED, input, (long) row * Integer.BYTES, ByteOrder.nativeOrder()); state[slot] = ((IntVector) state[slot]).lanewise(reducer, values);}Testing it on input fed from seqgen, stats1 --field A --accumulators min,max compiles to:
mov r14d, edxadd r14d, edivmovdqu32 zmm2, zmmword ptr [rbp + r14*4] ; load 16 ints from column Avpmaxsd zmm1, zmm1, zmm2 ; max reductionvpminsd zmm0, zmm0, zmm2 ; min reductionlea edx, [rdx + 0x10]cmp r13d, edxjg loop ; back to topOne load feeds both reductions and there are no remnants of Java objects or the heap to be seen anywhere in the hot loop.
What does C2 need for peak performance?
As mentioned before, while reification of functions and loops works just fine on C2, using Object[] as a mutable, heterogeneous state carrier does not get scalar-replaced the way we need.
I explored what a C2-friendly implementation would have to look like instead.
Local variables are a must, which limits this attempt to work only with a single int field as input, and supports up to three reductions.
It has no flexibility and to push it further than this would require you to write a lot more of this boilerplate manually, which I did not bother with.
Also notable is that we cannot use @Derived and have to instead build up the three individual reductions outside of the function, then pass it in.
@Reified(extending = BatchOp.class)static int kernel( @Constant MemorySegment input1, @Constant MemorySegment input2, @Constant MemorySegment input3, @Constant Stats1_Reified.KernelInitialize init1, @Constant Stats1_Reified.KernelInitialize init2, @Constant Stats1_Reified.KernelInitialize init3, @Constant Stats1_Reified.KernelChunk chunk1, @Constant Stats1_Reified.KernelChunk chunk2, @Constant Stats1_Reified.KernelChunk chunk3, @Constant Stats1_Reified.KernelMaskedChunk maskedChunk1, @Constant Stats1_Reified.KernelMaskedChunk maskedChunk2, @Constant Stats1_Reified.KernelMaskedChunk maskedChunk3, @Constant Stats1_Reified.KernelFlusher flush1, @Constant Stats1_Reified.KernelFlusher flush2, @Constant Stats1_Reified.KernelFlusher flush3, int control) { final boolean eof = controlOutputComplete(control); final int rowCount = controlRowCount(control); final var species = IntVector.SPECIES_PREFERRED; final int lanes = species.length(); final int fullCount = species.loopBound(rowCount); var a1 = init1 != null ? init1.apply() : IntVector.zero(species); var a2 = init2 != null ? init2.apply() : IntVector.zero(species); var a3 = init3 != null ? init3.apply() : IntVector.zero(species); for (int row = 0; row < fullCount; row += lanes) { final long offset = (long) row * Integer.BYTES; if (chunk1 != null) { final var values = IntVector.fromMemorySegment(species, input1, offset, ByteOrder.nativeOrder()); a1 = chunk1.apply(a1, values); } if (chunk2 != null) { final var values = IntVector.fromMemorySegment(species, input2, offset, ByteOrder.nativeOrder()); a2 = chunk2.apply(a2, values); } if (chunk3 != null) { final var values = IntVector.fromMemorySegment(species, input3, offset, ByteOrder.nativeOrder()); a3 = chunk3.apply(a3, values); } }19 collapsed lines
if (fullCount < rowCount) { final var active = species.indexInRange(fullCount, rowCount); final long offset = (long) fullCount * Integer.BYTES; if (maskedChunk1 != null) { final var values = IntVector.fromMemorySegment(species, input1, offset, ByteOrder.nativeOrder(), active); a1 = maskedChunk1.apply(a1, values, active); } if (maskedChunk2 != null) { final var values = IntVector.fromMemorySegment(species, input2, offset, ByteOrder.nativeOrder(), active); a2 = maskedChunk2.apply(a2, values, active); } if (maskedChunk3 != null) { final var values = IntVector.fromMemorySegment(species, input3, offset, ByteOrder.nativeOrder(), active); a3 = maskedChunk3.apply(a3, values, active); } } if (flush1 != null) flush1.apply(a1, control); if (flush2 != null) flush2.apply(a2, control); if (flush3 != null) flush3.apply(a3, control); return control(eof, eof ? 1 : 0);}Planning cache-fitted batches
We take three important lessons from our Polars investigation and apply them to this project:
- Strict schema planning and known datatypes for each column,
- Differentiate in-place transformations from out-of-place computations that need separate allocations,
- Size batches so that live data fits in cache.
We’ve already seen the Planner API enforcing the first through BatchLayout.
Every step in the pipeline being constructed gets to state its impact on the schema.
Number two is addressed by the distinction between an implicit schema passthrough and a dupStorage(column) declaration.
We’re going to address the final point in this section.
The schema evolution and allocation needs are recorded while the pipeline is being constructed. We then use that recorded information to make decisions about sizing & allocations before the stages are finalized. Only once that’s done are pipeline steps compiled and reified.
To inform our planning, we can discover the cache topology at runtime:
static long detectHWLocAttr(int cpu, String descendant, String attr) { try { Process p = new ProcessBuilder( "hwloc-info", "--descendants", descendant, "--get-attr", attr, "pu:" + cpu) .redirectError(ProcessBuilder.Redirect.DISCARD) .start();15 collapsed lines
String s; try (var in = p.getInputStream()) { s = new String(in.readAllBytes()).trim(); } if (p.waitFor() != 0 || s.isEmpty()) { throw new IllegalStateException("hwloc-info failed"); } final long value = Long.parseLong(s); if (value <= 0) throw new IllegalStateException("hwloc-info returned no cache capacity"); return value; } catch (IOException e) { throw new RuntimeException(e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); }}
static final long L1_DCACHE_SIZE = detectHWLocAttr(CPU_ID, "l1cache", "attr cache size");static final long L1_DCACHE_ALIGN = detectHWLocAttr(CPU_ID, "l1cache", "attr cache line size");Linux-only specialization
Shelling out to hwloc proved to be very slow in practice and worked against startup performance.
$ hyperfine -w 1 -r 100 -N "hwloc-info --descendants l1cache --get-attr 'attr cache size' pu:0"Benchmark 1: hwloc-info --descendants l1cache --get-attr 'attr cache size' pu:0 Time (mean ± σ): 51.7 ms ± 8.6 ms [User: 1.4 ms, System: 8.0 ms] Range (min … max): 43.0 ms … 64.8 ms 100 runs Instead, I only use it as fallback for a faster, Linux-only method that reads from sysfs directly.
Batch size is chosen not to exceed 80% of L1d, then rounded down so that every primitive column spans full cache lines (avoiding partial vectors and scalar epilogues). A different heuristic could easily be used, but this one seems good enough.
I’m using mlr4j to invoke my CLI while passing all the necessary JVM options, including inlining directives from the annotation processor through CompileCommandFile.
The --plan option additionally prints information about the shape of the compiled pipeline:
$ ./mlr4j --plan seqgen --stop 10 --field A then tail -n 3seqgen(field=A, start=1, stop=10, step=1) [batchSize=6528]- [column=A, state=added, start=0, end=26112, size=26112B]- [scratch=A_current, start=0, end=4, size=4B]tail(count=3) [batchSize=6528]- [column=A, state=inherited, start=0, end=26112, size=26112B]- [scratch=col=A, start=4, end=16, size=12B]Print(TSV) [batchSize=6528]- [column=A, state=inherited, start=0, end=26112, size=26112B]total: columnData=26112B, scratch=16B
A8910Compared to a wider schema:
$ ./mlr4j --plan seqgen --stop 1000 --field A then put '$B = $A * 5; $C = 42' then headseqgen(field=A, start=1, stop=1000, step=1) [batchSize=2176]- [column=A, state=added, start=0, end=8704, size=8704B]- [scratch=A_current, start=0, end=4, size=4B]put(dsl=$B = $A * 5; $C = 42) [batchSize=2176]- [column=A, state=inherited, start=0, end=8704, size=8704B]- [column=C, state=added, start=8704, end=17408, size=8704B]- [column=B, state=added, start=17408, end=26112, size=8704B]head(count=10) [batchSize=2176]- [column=A, state=inherited, start=0, end=8704, size=8704B]- [column=C, state=inherited, start=8704, end=17408, size=8704B]- [column=B, state=inherited, start=17408, end=26112, size=8704B]- [scratch=head_remaining, start=4, end=8, size=4B]Print(TSV) [batchSize=2176]- [column=A, state=inherited, start=0, end=8704, size=8704B]- [column=C, state=inherited, start=8704, end=17408, size=8704B]- [column=B, state=inherited, start=17408, end=26112, size=8704B]total: columnData=26112B, scratch=8B
A B C1 5 429 collapsed lines
2 10 423 15 424 20 425 25 426 30 427 35 428 40 429 45 4210 50 42In both cases, we see the planner utilize the same 24 KiB worth of space — well within the L1d.
Getting alignment right makes a big difference on this Zen 4 CPU.
This is measured with perfnorm:events=ls_misal_loads.ma64 and also -XX:UseAVX=2 to avoid some weirdness this PMU counter reports with 512-bit vectors.
The slower run aligns the column data only to the element size (4 bytes), but not the vector size (32 bytes for AVX2).
Benchmark Mode Cnt Score Error Unitsaligned avgt 9 19.776 ± 0.192 ms/opaligned:ls_misal_loads.ma64:u avgt 3 324.725 ± 1470.288 #/op
unaligned avgt 9 25.981 ± 0.718 ms/opunaligned:ls_misal_loads.ma64:u avgt 3 34838767.554 ± 25320713.563 #/opThis is the big reason MemorySegments are used to begin with: we can’t allocate primitive arrays with specific alignment.
Using off-heap Arenas lets us control this aspect and get the best performance out of the hardware.
Memory allocated this way also stays pinned and is never moved by the Garbage Collector.
That’s what allows us to constant fold and inline raw addresses in the middle of the instruction stream.
Our planning & sizing decisions maximize the spatial locality of the workload. The overall batch-based iterator model of the pipeline gives us temporal locality — a batch is pushed through the entire pipeline before the next one can begin. The combination of these techniques lets us maximize the uplift from very fast caches.
Performance
The immediate issue we run into when trying to compare performance against Miller is that the JVM will take a long time to warm up. For a simple in-memory workload, Miller can process anywhere between 50K–400K rows before my program even initializes:
$ hyperfine -N \ 'mlr --tsv seqgen -f A --stop 50000 then put "$A = $A * 2" then stats1 -f A -a min' \ 'java --version' \ 'mlr --tsv seqgen -f A --stop 400000 then put "$A = $A * 2" then stats1 -f A -a min' \ './mlr4j --help'Benchmark 1: mlr --tsv seqgen -f A --stop 50000 then put "$A = $A * 2" then stats1 -f A -a min Time (mean ± σ): 23.4 ms ± 2.0 ms [User: 26.2 ms, System: 6.0 ms] Range (min … max): 19.4 ms … 29.1 ms 149 runs Benchmark 2: java --version Time (mean ± σ): 27.9 ms ± 1.0 ms [User: 43.3 ms, System: 11.8 ms] Range (min … max): 25.9 ms … 31.1 ms 102 runs Benchmark 3: mlr --tsv seqgen -f A --stop 400000 then put "$A = $A * 2" then stats1 -f A -a min Time (mean ± σ): 138.0 ms ± 6.4 ms [User: 170.7 ms, System: 16.2 ms] Range (min … max): 124.6 ms … 148.1 ms 23 runs Benchmark 4: ./mlr4j --help Time (mean ± σ): 138.7 ms ± 4.2 ms [User: 331.6 ms, System: 34.8 ms] Range (min … max): 134.0 ms … 151.7 ms 19 runs Summary mlr --tsv seqgen -f A --stop 50000 then put "$A = $A * 2" then stats1 -f A -a min ran 1.19 ± 0.11 times faster than java --version 5.90 ± 0.57 times faster than mlr --tsv seqgen -f A --stop 400000 then put "$A = $A * 2" then stats1 -f A -a min 5.93 ± 0.53 times faster than ./mlr4j --helpThe optimized assembly I’ve been showing off until this point came out of warmed-up JMH benchmarks and is not representative of the above CLI invocations.
They have to boot up, don’t get to warm up ahead of time and are measured end-to-end.
Shorter pipelines primarily finish while executing their C1-compiled versions, before the top-tier compiler finishes or even gets invoked at all.
We have some control over when this happens with -XX:Tier4InvocationThreshold.
In practice, however, the savings from a substantially lowered threshold are modest: 8%, or ~40 ms.
And if we crank the thresholds too much, it may compile too soon and have to deoptimize and go through the same cycle again, multiple times.
The timeline makes this clearer: lowering the threshold can shrink the red C1 pipeline window, but it doesn’t have an effect on anything else.
The span on the last row is my estimate for what the recent startup improvements in the JVM could save in this run.
It’s the AOT cache speeding up my project’s class loading plus an estimated saving I would have gotten if using jdk.incubator.vector didn’t prevent the boot layer from being archived.
There may be even more to win by recording method profiling data for pipelines built from miller’s own test suite.
This should certainly speed up warmup of the generation code itself, but possibly also some of the kernels, like the stats1 min reducer over int data.
Overall, we have to balance the opposing concerns of the CPU and the JIT compiler. What’s best for the CPU is to use up all the space in the L1 cache, make the loops as long as possible and maximize the amount of work done under a predictable regime. But what’s best for the JIT is smaller batches, to it sees profiling data from as much of the pipeline as it can before compiling native code.
Still, with large enough datasets, mlr4j will eventually catch up and surpass Miller:
$ hyperfine -N --style color \ 'mlr --tsv seqgen -f A --stop 20000000 then put "$A = $A * 2" then stats1 -f A -a min' \ './mlr4j seqgen -f A --stop 20000000 then put "$A = $A * 2" then stats1 -f A -a min'Benchmark 1: mlr --tsv seqgen -f A --stop 20000000 then put "$A = $A * 2" then stats1 -f A -a min Time (mean ± σ): 5.282 s ± 0.093 s [User: 6.616 s, System: 0.422 s] Range (min … max): 5.152 s … 5.464 s 10 runs Benchmark 2: ./mlr4j seqgen -f A --stop 20000000 then put "$A = $A * 2" then stats1 -f A -a min Time (mean ± σ): 338.1 ms ± 6.2 ms [User: 919.9 ms, System: 49.0 ms] Range (min … max): 327.7 ms … 348.6 ms 10 runs Summary ./mlr4j seqgen -f A --stop 20000000 then put "$A = $A * 2" then stats1 -f A -a min ran 15.62 ± 0.40 times faster than mlr --tsv seqgen -f A --stop 20000000 then put "$A = $A * 2" then stats1 -f A -a minI’ve also added a built-in benchmarking facility for the CLI. It compiles the pipeline once and then reuses it across runs.
$ ./mlr4j --benchmark 200,200 seqgen -f A --stop 1000000 then put '$A = $A * 2' then stats1 -f A -a min | mlr --d2m cat| min | p25 | p50 | p75 | max |
|---|---|---|---|---|
| 0.067ms | 0.069ms | 0.070ms | 0.074ms | 0.112ms |
This does two hundred warmup runs followed by two hundred measurement runs. Only a small fraction of the end-to-end CLI wall time is spent in the actual optimized pipeline. It’s also 2.4x faster than the Polars example we opened with. And yes, I did just use Miller to convert the output to a Markdown table I can embed in this post.
Overall, comparing Miller like this is not very representative.
It uses a single chunk of memory to pass the entire seqgen output to the downstream consumer, which means the data quickly spills out of CPU caches and into DRAM.
$ systemd-run --user --pty --wait --collect --expand-environment=no \ mlr --tsv seqgen -f A --stop 1000000 then put '$B = $A % 4' then stats1 -f A -a min,max,sum > /dev/null Finished with result: success Main processes terminated with: code=exited, status=0/SUCCESS Service runtime: 371ms CPU time consumed: 417ms Memory peak: 719.6M (swap: 0B)
$ systemd-run --user --pty --wait --collect --expand-environment=no \ mlr --tsv seqgen -f A --stop 10000000 then put '$B = $A % 4' then stats1 -f A -a min,max,sum > /dev/null Finished with result: success Main processes terminated with: code=exited, status=0/SUCCESS Service runtime: 3.308s CPU time consumed: 4.775s Memory peak: 5.9G (swap: 0B)Runtime speculative specialization
One thing I commonly use Miller for is generating stats summaries of one field grouped by distinct values of another field. Something like this pattern:
$ mlr --tsv \ seqgen -f A --start 10 --stop 30 \ then put '$B = $A % 4' \ then stats1 -f A -g B -a minB A_min2 103 110 121 13To implement this generically, we basically need a hash map.
But the common case for me is having just a few buckets, often boolean categories, rather than large, unbounded maps.
This way, it’s closer to a histogram with a predetermined number of bins than to a GROUP BY.
We want a specialized implementation for low-cardinality reductions, such as the example above, which fits in four buckets.
Instead of using hash maps, we can use lane masking to apply operations conditionally — only to the lanes holding rows that match the key of the group being evaluated.
This lets us avoid slow & generic Java collections, which involve pointer-chasing through the heap and wouldn’t be a good fit for the high-performance pipeline that is the theme of this post.
But adding the grouping functionality to our stats1 implementation is not just a simple extension.
We don’t know what the group keys are ahead of time — we don’t even know how many unique keys there might be.
So the implementation needs a lazy discovery process.
We could hardcode a single implementation for all reductions up to, say, four bins.
That would work for our example above, but it would waste computation (and some memory) on lower-cardinality inputs.
Taking inspiration from the JVM, I decided to add runtime specialization.
Every PipelineStage can opt in by implementing a separate interface.
During execution, when stats1 encounters a new group and wants to switch to a higher-cardinality version of itself, it throws a specific exception that includes the new specialization object.
That object describes the parameters the stage should be compiled with in the successor pipeline.
The driver then uses this information to assemble the new pipeline.
This includes asking the newly specialized stage to transfer any scratch and column data it needs from the old copy.
This way, grouped stats1 always starts at K=1, not knowing anything about the input, and respecializes as it goes.
The unknown-group guard
The K=1 kernel learns its first key from row zero.
Each group-column vector is then compared against the K known keys; the OR of those masks says which lanes matched a known group.
Unknown lanes are recognized during iteration, but their handling is deferred until the end of the batch.
The implementation has the same shape as the ungrouped kernel we’ve seen before.
Additionally, we’ve got @Constant int groupCapacity to represent K and groupData points to the column used in -g.
There’s a new @ReifiedLoop known to help us identify unknown group keys we’re seeing for the first time.
final Object[] state = new Object[groupCapacity * grid.slots()];init.invoke(state);var allKnown = species.maskAll(true);for (int r = 0; r < fullCount; r += lanes) { final long offset = (long) r * Integer.BYTES;
final var groupKeys = IntVector.fromMemorySegment(species, groupData, offset, ByteOrder.nativeOrder());
final var values = IntVector.fromMemorySegment(species, input, offset, ByteOrder.nativeOrder());
// @ReifiedLoop can thread state through its steps; `known` reduces a mask this way final var knownMask = known.invoke(groupKeys, /* initial state = */ species.maskAll(false)); allKnown = allKnown.and(knownMask);
chunk.invoke(state, groupKeys, values);}// masked epilogue omittedif (!allKnown.allTrue()) throw registerFirstUnknownAndRespecialize(...);flush.invoke(state, control);The above code has been simplified to handle single-field reductions only. The real implementation needs an extra inner loop that goes over each field.
Throwing directly from the vector loop would keep the Object[] state materialized, so we defer it instead.
New groups appearing in the input are expected to be rare events, so we optimize for this scenario by keeping the hot loop short.
This approach is safe because we don’t flush the accumulators to scratch data until we check for any new groups in the current batch.
The exception’s deferral only causes us to waste some work towards the current batch, before we restart.
Deeper inside the chunk processor, our code that processes one group-field-reduction slot of the StatGrid now handles lane-masking:
@Reifiedstatic void groupedChunkStep( @Constant int slotIndex, @Constant int groupIx, @Constant VectorOperators.Associative reducer, @Constant MemorySegment groupValues, Object[] state, IntVector groupKeys, IntVector values) { final int gv = groupValues.get(ValueLayout.JAVA_INT, (long) groupIx * Integer.BYTES); final var mask = groupKeys.eq(gv); state[slotIndex] = ((IntVector) state[slotIndex]).lanewise(reducer, values, mask);}Once everything inlines together, we rely on Graal’s CSE to deduplicate our group key comparisons.
For K=2 with min,max,sum, the hottest part of the generated loop is:
kxnorw k1, k0, k0 ; allKnown = maskAll(true) movabs rdi, 0x78c5e5a00008 ; &groupValues[0] movabs rdx, 0x78c5e5a0000c ; &groupValues[1] movabs rbp, 0x78c608203300 ; the -g column movabs r13, 0x78c608200000 ; the column being reduced mov r8d, dword ptr [rdi] vpbroadcastd zmm8, r8d mov r8d, dword ptr [rdx] vpbroadcastd zmm9, r8d mov r8d, r12d ; r = 0
↗ mov r14d, r8d│ add r14d, ebx│ vmovdqu32 zmm10, zmmword ptr [rbp + r14*4] ; load 16 group keys│ vpcmpeqd k3, zmm10, zmm8 ; first known group│ vpcmpeqd k2, zmm10, zmm9 ; second known group│ korw k4, k2, k3│ kandw k1, k4, k1 ; allKnown &= that│ vmovdqu32 zmm10, zmmword ptr [r13 + r14*4] ; load 16 values│ vpaddd zmm0 {k3}, zmm0, zmm10 ; first-group sum│ vpmaxsd zmm1 {k3}, zmm1, zmm10 ; first-group max│ vpmaxsd zmm6 {k2}, zmm6, zmm10 ; second-group max│ vpaddd zmm5 {k2}, zmm5, zmm10 ; ...│ vpminsd zmm3 {k3}, zmm3, zmm10│ vpminsd zmm7 {k2}, zmm7, zmm10│ lea r8d, [r8 + 0x10]│ cmp r10d, r8d╰ jg loop
kortestw k1, k1 jae respecialize ; if (!allKnown.allTrue()) throw, recompile for K + 1The resulting code produces exactly two group key comparisons.
Their masks are then shared by the three reductions, while the six Object[] slots stay in vector registers.
We can see the respecialization happen with --plan.
Recall that we start each grouped stats1 from the single-group specialization, and the script we use reveals a second group key after the first eleven rows:
$ ./mlr4j --plan \ seqgen --stop 20 -f A \ then put '$B = $A > 10 ? $A % 2 : 1' \ then stats1 -f A -g B -a min,maxseqgen(field=A, start=1, stop=20, step=1) [batchSize=3264]- [column=A, state=added, start=0, end=13056, size=13056B]- [scratch=A_current, start=0, end=4, size=4B]put(dsl=$B = $A > 10 ? $A % 2 : 1) [batchSize=3264]- [column=A, state=inherited, start=0, end=13056, size=13056B]- [column=B, state=added, start=13056, end=26112, size=13056B]stats1(fields=[A], accumulators=[min, max], groupFields=[B]) [K=1] [batchSize=3264 -> 1]4 collapsed lines
- [column=A, state=dropped, start=0, end=13056, size=13056B]- [column=B, state=reallocated, start=26112, end=26116, size=4B, previousStart=13056, previousEnd=26112, previousSize=13056B]- [column=A_min, state=added, start=26116, end=26120, size=4B]- [column=A_max, state=added, start=26120, end=26124, size=4B]- [scratch=group=B_count, start=4, end=8, size=4B]- [scratch=group=B_values, start=8, end=16, size=8B]- [scratch=A_min, start=16, end=20, size=4B]- [scratch=A_max, start=20, end=24, size=4B]6 collapsed lines
Print(TSV) [batchSize=1]- [column=B, state=inherited, start=26112, end=26116, size=4B]- [column=A_min, state=inherited, start=26116, end=26120, size=4B]- [column=A_max, state=inherited, start=26120, end=26124, size=4B]total: columnData=26124B, scratch=24B
respecializing plan at step 2: Stats1Specialization[groupK=1] -> Stats1Specialization[groupK=2]6 collapsed lines
seqgen(field=A, start=1, stop=20, step=1) [batchSize=3264]- [column=A, state=added, start=0, end=13056, size=13056B]- [scratch=A_current, start=0, end=4, size=4B]put(dsl=$B = $A > 10 ? $A % 2 : 1) [batchSize=3264]- [column=A, state=inherited, start=0, end=13056, size=13056B]- [column=B, state=added, start=13056, end=26112, size=13056B]stats1(fields=[A], accumulators=[min, max], groupFields=[B]) [K=2] [batchSize=3264 -> 2]- [column=A, state=dropped, start=0, end=13056, size=13056B]- [column=B, state=reallocated, start=26112, end=26120, size=8B, previousStart=13056, previousEnd=26112, previousSize=13056B]- [column=A_min, state=added, start=26120, end=26128, size=8B]- [column=A_max, state=added, start=26128, end=26136, size=8B]- [scratch=group=B_count, start=4, end=8, size=4B]- [scratch=group=B_values, start=8, end=20, size=12B]- [scratch=A_min, start=20, end=28, size=8B]- [scratch=A_max, start=28, end=36, size=8B]5 collapsed lines
Print(TSV) [batchSize=2]- [column=B, state=inherited, start=26112, end=26120, size=8B]- [column=A_min, state=inherited, start=26120, end=26128, size=8B]- [column=A_max, state=inherited, start=26128, end=26136, size=8B]total: columnData=26136B, scratch=36BB A_min A_max1 1 190 12 20Increasing the cardinality requires us to allocate more scratch data to store accumulators, the new group key (space for one more key is always reserved for further discovery and widening).
Performance
Let’s now benchmark the speedup we get from sticking to low-cardinality specializations.
Both pipelines below eventually arrive at the same shape; one widens early on, at 10% of the way through the stream and the other stays on K=1 until 90%.
The built-in benchmark mode caches all compiled pipelines, compiling each specialization once, and starts every measurement run from the same K=1 variant, letting them switch at will.
$ { echo -n 'widen_at=10%,' ./mlr4j --benchmark 5,10 \ seqgen --stop 1000000000 -f A \ then put '$B = $A > 100000000 ? $A % 4 : 0' \ then stats1 -f A -g B -a min,max,sum echo -n 'widen_at=90%,' ./mlr4j --benchmark 5,10 \ seqgen --stop 1000000000 -f A \ then put '$B = $A > 900000000 ? $A % 4 : 0' \ then stats1 -f A -g B -a min,max,sum} | mlr --d2m cat| widen_at | min | p25 | p50 | p75 | max |
|---|---|---|---|---|---|
| 10% | 696.403ms | 696.846ms | 697.000ms | 697.981ms | 698.511ms |
| 90% | 375.876ms | 376.501ms | 376.692ms | 377.530ms | 377.683ms |
Keeping the single-group specialization for 90% of the input speeds up the overall execution by 1.85x.
The limiter for how complex we can get with this approach is the number of registers. AVX-512 gives us 32, which should be good enough for 4 groups of 3 reductions over 2 fields, give or take. Any more and we start spilling, which introduces more L1 traffic that competes with streaming in input values. There is another issue though, and a long-standing one at that — the Graal JIT can only allocate the lower 16, which is a ceiling we can hit rather quickly.
Fusing pipeline stages
The batch-oriented pipeline is very flexible in its current form. It can support stages that are sources for the pipeline, row-oriented transformations, reductions and amplifications, all through the same interface. Each step will basically be called, with optional inputs if prior stages are still active, until it returns an EOF signal.
The downside is that it requires a round trip through memory.
Even when we size the batches to stay within the L1 cache, that’s still going to be a bottleneck for sufficiently simple transformations.
The processor I’m running this on has about 5x as much vector register bandwidth as its L1 does.
That means there’s more to gain if we skip storing the data in columns, and instead rely on our battle-tested Object[] to pass data between stages.
This iteration protocol is much worse to work with than the batch one. I don’t want to go into the details of it; I plan on redoing it and focusing on fusing just the map/reduce stages rather than trying to make everything fit into this model. Instead, I wanted to highlight new limitations I encountered and how they relate to the reification theme of this article.
Passing data in registers
The fused compiler assigns values to typed slots in an Object[] register file.
Because the slot indices are @Constant, Graal can scalar-replace the array and its IntVector wrappers into machine registers.
Values of the same type with non-overlapping lifetimes can reuse a slot:
seqgen A -> allocates slot 0A = A << 10 -> slot 0 (reused/in-place)min(A), sum(A) <- consumes 0The script reads the old binding of A, releases it, and gets the same slot back for the result.
After min and sum consume the shifted vector, slot 0 is semantically dead and will be overwritten on the next iteration.
This is very much a generalization of the approach we saw earlier in grouped stats1.
This time, however, the varying lifetimes of slots caused some issues for the Graal compiler.
I shrank the reproduction to show the root cause more easily:
var registers = new Object[] { IntVector.zero(SPECIES), IntVector.broadcast(SPECIES, Integer.MAX_VALUE), IntVector.zero(SPECIES)};IntVector seq = IntVector.zero(SPECIES).addIndex(1); // [0, 1, 2, ...]
for (int base = 0; base < OPERATIONS; base += SPECIES.length()) { registers[0] = seq; registers[0] = ((IntVector) registers[0]).lanewise(VectorOperators.LSHL, 10); var value = (IntVector) registers[0]; registers[1] = ((IntVector) registers[1]).lanewise(VectorOperators.MIN, value); registers[2] = ((IntVector) registers[2]).add(value); seq = seq.add(SPECIES.length());}This example feels a little contrived with a local seq variable, but it has the same shape of what I once used in my fusion implementation.
The array and vector allocations are eliminated as expected, but one of the values is still spilled to the stack.
vpaddd zmm3, zmm2, zmm0 ; next seqvpslld zmm2, zmm2, 0xa ; A * 1024vpminsd zmm4, zmm2, zmm4 ; minvpaddd zmm1, zmm2, zmm1 ; sumlea r10d, [r10 + 0x10]vmovdqu32 zmmword ptr [rsp], zmm3 ; spillvmovdqu32 zmm3, zmm2vmovdqu32 zmm2, zmmword ptr [rsp] ; reloadcmp r11d, r10dja loopThe fix is to explicitly kill the slot after its final use:
registers[2] = ((IntVector) registers[2]).add(value); registers[0] = IntVector.zero(SPECIES); seq = seq.add(SPECIES.length());The zero does not survive into the generated code. The backedge then needs no stack temporary:
vpaddd zmm8, zmm7, zmm1 ; next seqvpslld zmm7, zmm7, 0xa ; A * 1024vpminsd zmm6, zmm7, zmm6 ; minlea r9d, [r9 + 0x10]vpaddd zmm5, zmm7, zmm5 ; sumvmovdqu32 zmm7, zmm8cmp ecx, r9dja loopDealing with long-lived Object[] register files where each slot varies in lifetime makes the fused approach a difficult sell.
We have to implement our own register allocation, liveness analysis, and then sprinkle in explicit resets to avoid spills.
By comparison, the batch implementation is free from all of this.
Avoiding deopt guards
Similar to the batched implementation of stats1, the fused version of this stage speculates that every key belongs to one of the groups in its current specialization.
After each vector of 16 rows, it checks whether any lane violated that assumption:
final var unknown = masks.known().not();if (unknown.anyTrue()) { throw registerUnknownFusedGroup(...);}This is our speculative guard: taking it throws a restart that transfers execution to a wider pipeline.
But in moving this branch from happening once per batch to once per vector, we’re evaluating it far more frequently.
This causes an unintended interaction when HotSpot profiles this branch and doesn’t ever see it being taken for the first N rows.
When the optimized compiler skips this branch and leaves a deopt guard in its place, we pay a much larger cost when we eventually take this branch for the first time, like in our widen_at examples above.
Our pipeline cache is stable and does its job well, but we get a JVM-caused invalidation and recompile anyway.
$ JAVA_OPTIONS='-D miller4j.fused=true -XX:+UnlockDiagnosticVMOptions -XX:+TraceDeoptimization -XX:+PrintCompilation' \ ./mlr4j --benchmark 0,1 \ seqgen --stop 4000000 -f A \ then put '$B = $A > 3000000 ? 1 : 0' \ then stats1 -f A -g B -a min,max,sumUNCOMMON TRAP method=jdk.incubator.vector.Int512Vector$Int512Mask.anyTrue()Z bci=26 ... reason=null_assert_or_unreached0 action=reinterpret4964 2542 % ! 4 dev.miller4j.Main$CompiledPipelineSession::drive @ 10 (185 bytes) made not entrant: uncommon trap...VFrame 1 ... dev.miller4j.GeneratedReifiedCloned52/...fusedComposableGroupedPipelineStage$spec(...) - invokevirtual @ bci=328Our unknown.anyTrue() is the trigger and it switches execution back to the interpreter when it’s eventually taken.
This is destructive interference between our compiler and the JVM’s.
I tried to use a Graal-only workaround to fake branch probability and make sure the body is compiled, even if it’s never observed as taken during profiling:
if (GraalDirectives.injectBranchProbability(0.01, unknown.anyTrue())) { throw registerUnknownFusedGroup(...);}But this only moved the deopt reason deeper and did not fully resolve the issue. I think it’d actually need to enter this branch while the code is being profiled, but that’s just my speculation. Yet again, we run into difficulty that’s unique to fused compilation. The batch implementation is more resilient to this scenario:
- It doesn’t need these high-frequency almost-never-taken branches,
- We can force each step to
dontinlineand form a separate compilation unit. This will reduce the scope of recompilation in the worst case.
Performance
If we push past these issues and pick a pipeline specification that doesn’t trigger the catastrophic behaviors mentioned earlier, what is the speedup? I measured the pipeline from the introduction of this post, with and without fusion.
Benchmark Mode Cnt Score Error Unitsbatch avgt 9 0.076 ± 0.002 ms/opfused avgt 9 0.037 ± 0.001 ms/opFusion makes this pipeline twice as fast, which is far less than the 5x advantage the vector register file has over L1 load bandwidth.
The code is now likely dominated by data dependencies and latency between operations rather than peak throughput of the core.
I did not analyze this beyond checking some performance counters with -prof perfnorm:events=...:
# IPC = instructions / cycles# avx512Rate = fp_ops_retired_by_width.pack_512_uops_retired / cycles# fpStall = de_dis_dispatch_token_stalls1.fp_sch_rsrc_stall / cycles# loadWait = ex_no_retire.load_not_complete / cycles
batch:IPC 2.782 insns/clkbatch:avx512Rate 1.660 uops/clkbatch:fpStall 0.000 cycles/clkbatch:loadWait 0.191 cycles/clk
fused:IPC 1.454 insns/clkfused:avx512Rate 1.362 uops/clkfused:fpStall 0.757 cycles/clkfused:loadWait 0.003 cycles/clkThe fused variant spends virtually no cycles with retirement blocked by an outstanding load, but it retires fewer 512-bit ops per cycle.
Instead, dispatch of vector ops is frequently blocked because the schedulers are full.
This seems consistent with the core getting filled with dependency chains in the loop, though I’m not sure of that.
Breaking the dependencies by unrolling the loop could potentially unlock more performance, but it would be difficult to implement.
And as with register allocation and zeroing out values earlier, this feels like yet another compiler responsibility we’d need to reimplement ourselves.
Hypothetically, if that increased our ILP to bump the avx512Rate closer to its peak of 2 ops per cycle, we might extend the fused path’s advantage over batch to 2/1.362 * 0.076/0.037 =~ 3x.
Summary
I’ve introduced a manual reification method that is portable and works on both C2 and Graal.
It works with individual static functions to materialize new call sites that bind to specific runtime receivers and propagate constants to aid in optimization.
Entire loops can be unrolled by taking lists of such reified functions and similarly materializing whole pipelines.
This reification approach only generates the glue that invokes pre-existing, static methods.
It differs from monomorphization in static languages, which produces separate instances of functions for every set of parameters.
This allows us to write a single, static kernel to operate on generic Vector<E> arguments, for example.
Depending on which reified handle invokes the kernel and what specific types it passes in, it gets JIT-compiled into every version used, like {Short,Int,Long}{256,512}Vector.
What was not portable between the JVMs was scalar replacement.
All of our scaled-up examples relied on composing functionality through an Object[] carrier.
When accessed in one specific way ((T) state[constantIndex]) it proved to be a dependable tool.
We leaned on it heavily to compose state and allow every reified function to have dedicated storage akin to local variables spanning the entire compilation unit.
The batch-oriented iterator protocol proved easy to work with and was able to express all kinds of stages we needed.
Generation, amplification, map, reduce — most Miller verbs fit these archetypes, and they are all well supported in this model.
Passing data between stages in small MemorySegments enabled us to write fairly naive, often scalar code and rely on Graal to autovectorize it.
Since we folded our configuration and CLI arguments into @Constant state, the JIT performed its usual strength-reduction magic and other optimizations on our behalf — just like it would for ordinary, static code.
By contrast, while whole-pipeline fusion was possible and produced the most performant pipelines, it made for a fragile solution overall. It required a lot more attention to avoid performance pitfalls, its iteration protocol was far less compatible with the variety of stages we wanted to implement, and we ended up reimplementing logic that belongs in the compiler. There may be a middle ground where chains of 1 mapping stages can be fused together with a simplified interface, but that will be explored in the future.
Here’s my list of things to follow up on:
- Implement a vectorized file parser, reified to match the exact schema of the input file,
- Trade off some inlining in places that don’t need to scalarize objects to be able to cache and reuse compiled stages between different specializations,
- Cut down on the number of generated classes to speed up warmup,
- Add threading to run segments of the pipeline in parallel, passing batches through an SPSC queue,
- Use smaller data types as a speculative optimization, resizing on overflow,
- Revisit C2 measurements when Valhalla work lands,
- Investigate off-heap HashMap implementations to use for high-cardinality reductions.
Acknowledgements:
- Gergö Barany for the quick turnaround on Graal issues I’ve been reporting.
- John Kerl for Miller and the direct inspiration behind this project.
- Polars contributors for letting me forget the Pandas API completely, and for a very good execution engine too.
Appendix: Parsing files
The pipelines we’ve used are self-contained, in-memory examples that did not ingest any structured files. That’s Miller’s #1 purpose, but we did not get to explore it. We’ve seen how specializing stages to the schema, through reification, enabled the compiler to generate high-quality code. We could do the same to create a parser for the exact shape of the input, after pre-reading the beginning of the file. I’ve started exploring this, but it is not finished.
I’ve used the approach from Daniel Lemire’s blog — you subtract '0' from each ASCII digit and then compute a dot product against a base-10 position vector.
For example, (['1', '2', '3'] - '0') * [100, 10, 1] gives [100, 20, 3], which we can then sum up.
That’s the general idea, at least.
There’s more to it: you need to widen the element sizes as you go, since bytes stop at 255 and you want to parse larger numbers.
On x86, we want to use dedicated instructions that perform the multiply, sum, and extend lane sizes all in one.
Graal was missing these lowerings when I first needed them, which blocked me from proceeding.
I tried to work around the issue, but j.l.foreign can’t express vector registers in the calling convention, so I couldn’t implement the missing operations in native code.
Sulong did not seem to implement these SIMD instructions either.
These are the specific lowerings that were absent — each Java function here implements a single x86 instruction:
// __m128i _mm_madd_epi16 (__m128i a, __m128i b)// #include <emmintrin.h>// Instruction: pmaddwd xmm, xmm// CPUID Flags: SSE2static IntVector multiplyAddSignedShorts(ShortVector a, ShortVector b) { var aEven = a.rearrange(SHORT_EVEN); var aOdd = a.rearrange(SHORT_ODD); var bEven = b.rearrange(SHORT_EVEN); var bOdd = b.rearrange(SHORT_ODD);
var aEvenI = (IntVector) aEven.convertShape(VectorOperators.S2I, IntVector.SPECIES_128, 0); var aOddI = (IntVector) aOdd.convertShape(VectorOperators.S2I, IntVector.SPECIES_128, 0); var bEvenI = (IntVector) bEven.convertShape(VectorOperators.S2I, IntVector.SPECIES_128, 0); var bOddI = (IntVector) bOdd.convertShape(VectorOperators.S2I, IntVector.SPECIES_128, 0);
return aEvenI.lanewise(VectorOperators.MUL, bEvenI) .lanewise(VectorOperators.ADD, aOddI.lanewise(VectorOperators.MUL, bOddI));}
// __m128i _mm_maddubs_epi16 (__m128i a, __m128i b)// #include <tmmintrin.h>// Instruction: pmaddubsw xmm, xmm// CPUID Flags: SSSE3static ShortVector multiplyAddUnsignedBytesSaturating(ByteVector a, ByteVector b) { var aEven = a.rearrange(BYTE_EVEN); var aOdd = a.rearrange(BYTE_ODD); var bEven = b.rearrange(BYTE_EVEN); var bOdd = b.rearrange(BYTE_ODD);
var aEvenS = (ShortVector) aEven.convertShape(VectorOperators.ZERO_EXTEND_B2S, ShortVector.SPECIES_128, 0); var aOddS = (ShortVector) aOdd.convertShape(VectorOperators.ZERO_EXTEND_B2S, ShortVector.SPECIES_128, 0); var bEvenS = (ShortVector) bEven.convertShape(VectorOperators.B2S, ShortVector.SPECIES_128, 0); var bOddS = (ShortVector) bOdd.convertShape(VectorOperators.B2S, ShortVector.SPECIES_128, 0);
return aEvenS.lanewise(VectorOperators.MUL, bEvenS) .lanewise(VectorOperators.SADD, aOddS.lanewise(VectorOperators.MUL, bOddS));}
4 collapsed lines
static final VectorShuffle<Short> SHORT_EVEN = VectorShuffle.makeUnzip(ShortVector.SPECIES_128, 0);static final VectorShuffle<Short> SHORT_ODD = VectorShuffle.makeUnzip(ShortVector.SPECIES_128, 1);static final VectorShuffle<Byte> BYTE_EVEN = VectorShuffle.makeUnzip(ByteVector.SPECIES_128, 0);static final VectorShuffle<Byte> BYTE_ODD = VectorShuffle.makeUnzip(ByteVector.SPECIES_128, 1);In the time it’s taken me to finish this article, Graal has added these lowerings and the above code compiles to the desired instructions on the latest Early Access build.
The nice thing about AVX-512 is that these instructions have 512-bit-wide equivalents as well. A 64-byte vector should be able to fit five signed integer columns in a row of TSV data. That’s five fields delimited by tabs, up to ten digits each, with an optional minus sign at the start. So a single pass of the algorithm could potentially do TSV record and field delineation and parse five integer columns at once.
It remains to be seen whether reducing memory loads will be a net benefit for this parser. We’ll need multiple shuffle operations to arrange everything for the base-10 dot products and such. I might do a follow-up post exploring just that.