Why resort to the complexities of dynamic code generation and compilation at runtime?
Speed.
To demonstrate, lets build an interpreter that runs projection and filtering on a simulated database, then build a compiled version and look at some performance numbers.
This is our goal: compile a data structure representing a query into native code to speed up a query loop. We'll look at two specific code-generation tools to achieve our goal: ASM and Scala Quasiquotes.
ASM has been used by Java developers for years for all sorts of codegen purposes. It's is very fast and has low-memory requirements, but it's also very low-level, making it time-consuming and tedious to use and very difficult to debug.
Quasiquotes are a new Scala tool for code generation. They are similar to macros, except they can be used at runtime whereas macros are compile-time only. They're much higher-level than ASM, so we'll compare benchmarks against the two to see what cost these high-levelel semantics incur, if any.
A query system
First let's define our "database" consisting of a single dataset, represented as a simple in-memory data structure using tuples as rows.
_12// Store a mapping of column name to ordinal for fast projection_12val schema = Seq("name", "birthYear", "dissertation").zipWithIndex.toMap_12// schema: scala.collection.immutable.Map[String,Int] =_12// Map(name -> 0, birthYear -> 1, dissertation -> 2)_12_12val db = Seq(_12 ("John McCarthy", 1927, "Projection Operators and Partial Differential Equations."),_12 ("Haskell Curry", 1900, "Grundlagen der kombinatorischen Logik"),_12 ("Philip Wadler", 1956, "Listlessness is Better than Laziness"),_12 ("Alonzo Church", 1903, "Alternatives to Zermelo's Assumption"),_12 ("Alan Turing", 1912, "Systems of Logic based on Ordinals")_12)
Next, we need structures that hold:
- fields to be projected
- filter expression tree
An efficient representation of projection fields is simply storing the ordinals. For example, this is how we'd represent a projection of the name and dissertation columns:
_10val projections = Seq(0, 2)
The filter expression is a little more complicated, since it's treeish in nature. Even though we're not supporting SQL, let's use it to help understand:
_10WHERE name != null AND birthYear < 1910
A tree is a natural way to represent this syntax in abstract form:
In Scala, we can represent this with a Scalaz Tree[String]
.
_10type FilterExpr = Tree[String]_10_10val filterExpr: FilterExpr = And.node(_10 IsNotNull.node(_10 Item.node("name".leaf)),_10 LessThan.node(_10 Item.node("birthYear".leaf),_10 Literal.node("1910".leaf)))
The root node of each tree or subtree is always an operator. The sub-forests are the operands, which may be made up of operator trees.
Using this we can build a filter interpreter that evaluates whether a row should be included. Some caveats about limitations of this simple example:
- We'll run filtering before projection. In reality, which to run first is a decision that should be made by a query optimizer.
- I'm only going to implement a few of the most common operators.
- The filter interpreter is doing its own projection, which may overlap with the projected fields requested by the user and create duplicate work.
- I'm only supporting
Int
for comparison, and I'm doing some nasty type casting. IRL, we'd keep better track of the types with a full blown schema.
These are all issues I won't address.
Here's our limited set of operators:
_10object Operators {_10 val And = "AND"_10 val Or = "OR"_10 val IsNotNull = "IS_NOT_NULL"_10 val LessThan = "LESS_THAN"_10 val Item = "ITEM"_10 val Literal = "LITERAL"_10}_10import Operators._
And the interpreter itself:
_27class FilterInterpreter(expr: FilterExpr, schema: Map[String, Int]) {_27 import Operators.__27 /** return true if row is filtered out by expr */_27 def isFiltered(row: Product): Boolean = evalFilterOn(row)_27_27 private def evalFilterOn(row: Product): Boolean = {_27 def eval(expr: FilterExpr): Boolean = {_27 val (operator, operands: Stream[FilterExpr]) = (expr.rootLabel, expr.subForest)_27 operator match {_27 case And => operands.forall(eval)_27 case Or => operands.exists(eval)_27 case IsNotNull => valueFor(operands(0)) != null_27 case LessThan => {_27 val (x :: y :: _) = operands.map(o => valueFor(o).toString.toInt).toList_27 x < y_27 }_27 }_27 }_27 def valueFor(node: FilterExpr) = node.rootLabel match {_27 // Item expects a single operand_27 case Item => row.productElement(schema(node.subForest.head.rootLabel))_27 // Literal expects a single operand_27 case Literal => node.subForest.head.rootLabel_27 }_27 eval(expr)_27 }_27}
And finally, a simple query loop:
_10def query(projections: Seq[Int], filterExpr: FilterExpr): Seq[Seq[Any]] = {_10 val filterer = new FilterInterpreter(filterExpr, schema)_10 db.flatMap { row =>_10 // Filter_10 if (filterer.isFiltered(row)) None_10 // Project_10 else Some(projections.map(row.productElement))_10 }_10}
NB: we could use ScalaBlitz to optimize that
flatMap
if this was real and we were ultra-concerned about
performance.
Notice how we return Seq[Seq[Any]]
. At compile-time we don't know how to
typefully represent a row so we have to resort to the lowest-common type,
Any
. This is another issue that we'll fixup later in the
compiled version.
With all this in place, let's run it:
_17// SELECT name, dissertation_17val projections = Seq(0, 2)_17_17// WHERE name != null AND birthYear < 1910_17val filterExpr: FilterExpr = And.node(_17 IsNotNull.node(_17 Item.node("name".leaf)),_17 LessThan.node(_17 Item.node("birthYear".leaf),_17 Literal.node("1910".leaf)))_17_17val result = query(projections, filterExpr)_17result.map(println)_17_17//=> List(John McCarthy, Projection Operators and Partial Differential Equations.)_17//=> List(Philip Wadler, Listlessness is Better than Laziness)_17//=> List(Alan Turing, Systems of Logic based on Ordinals)
So far so good. Next up, let's dive into some bytecodes to gain a basic understanding of what's going on when we generate code on the JVM.
Bytecode primer
Let's start by looking at the bytecode for a minimum viable Scala class:
_10class Foo
Compile it with scalac
then view the bytecode with java -c
:
_10scalac Foo.scala_10javap -c Foo.class
_10public class Foo {_10 public Foo();_10 Code:_10 0: aload_0_10 1: invokespecial #12 // Method java/lang/Object."<init>":()V_10 4: return_10}
From the Java bytecode instructions listings reference we can find out the meaning of these bytecode instructions:
aload_0
— load a reference onto the stack from local variable 0 (local var 0 is alwaysthis
)invokespecial
— invoke instance method on object objectref (this would be the object that we just loaded onto the stack) and puts the result on the stack (might be void)return
— return void from method
This is the generated constructor for Foo
. Since nothing
else is going on, it simply returns void after calling the constructor. Now
what if we actually do something, like instantiate a Foo
?
_10class Foo_10_10object RunFoo {_10 val f = new Foo_10}
Compiling this yields a single Foo.class
identical to the one above along with
two class files: RunFoo.class
and RunFoo$.class
.
_10// RunFoo.class_10public final class RunFoo {_10 public static Foo f();_10 Code:_10 0: getstatic #16 // Field RunFoo$.MODULE$:LRunFoo$;_10 3: invokevirtual #18 // Method RunFoo$.f:()LFoo;_10 6: areturn_10}
_16// RunFoo$.class_16public final class RunFoo$ {_16 public static final RunFoo$ MODULE$;_16_16 public static {};_16 Code:_16 0: new #2 // class RunFoo$_16 3: invokespecial #12 // Method "<init>":()V_16 6: return_16_16 public Foo f();_16 Code:_16 0: aload_0_16 1: getfield #17 // Field f:LFoo;_16 4: areturn_16}
The JVM runs these opcodes in a stack machine: values are pushed on to the stack then used as operands to later operations. For example, this is how you could add two constants:
_10bipush 28_10bipush 14_10iadd
- Push 28 onto the stack with
bipush
- Push 14 onto the stack with
bipush
- Execute
iadd
which adds two ints: it pops two values off the stack to use as its operands: first 14, then 28. It adds those two operands and pushes the result, 28, onto the stack.
At this point we could work with the new 42 value on the stack. This is how we would check that the value is indeed 42:
_1020: bipush 42_1022: if_icmpne 28_1024: ldc #3_1026: goto 32_1028: ldc #4_1032: ...
- Push the value 42 onto the stack
- Use
if_icmpne
to compare two values from the stack. If they are not equal, jump to position 28, which pushes constant#4
onto the stack usingldc
. If they are equal, the next code is executed, which instead pushes constant#3
onto the stack, then jumps to position 32.
Tedious, but simple.
This primer is only intended to wet your feet. If you want to learn more about bytecode, see the Further reading section at the end of this post.
ASM
ASM is a bytecode manipulation framework. It's one of several options for manipulating bytecode, but I chose it because it's one of the most mature, requires the least amount of memory, and it's very fast. The downside is it's also quite low level. If you aren't super-concerned with performance, you should check out other options, like Javassist, which is much easier to work with.
Now, to use ASM, the more familiar you are with bytecode the better off you'll be, but for newbs like us, there is ASMifier, which takes a compiled class and generates the ASM code for it. I'm going to avoid Scala for this excercise, since Java maps more closely to bytecode, and we can use the resulting bytecode from Scala either way. I want to use Tuples to represent fully typed rows, so let's see what the ASM code looks like for this Java class:
_14import scala.Tuple2;_14_14public class TupleFromJava {_14_14 Tuple2<String, Integer> tup = new Tuple2<String, Integer>("foo", 2);_14_14 public TupleFromJava() {_14 }_14_14 public Tuple2<String, Integer> getTup() {_14 return tup;_14 }_14_14}
Compile it, making sure the scala-lib jar is on your classpath:
_10javac -cp $SCALA_LIB TupleFromJava.java
Then use the ASMifier on the classfile:
_10java -cp asm-5.0.3/lib/all/asm-all-5.0.3.jar \_10 org.objectweb.asm.util.ASMifier TupleFromJava.class
Here is the generated ASM code, heavily annotated with comments. It's a little tedious to work through, but if you really want to understand bytecode and ASM, I encourage you to read the comments and work through every line until it's internalized and you feel comfortable with it.
_118import java.util.*;_118import org.objectweb.asm.*;_118public class TupleFromJavaDump implements Opcodes {_118_118 public static byte[] dump () throws Exception {_118_118 ClassWriter cw = new ClassWriter(0);_118 FieldVisitor fv;_118 MethodVisitor mv;_118 AnnotationVisitor av0;_118_118 // Generate a public class inheriting from java.lang.Object_118 cw.visit(52, ACC_PUBLIC + ACC_SUPER, "TupleFromJava", null,_118 "java/lang/Object", null);_118_118 // Initialize the `tup` field with a null value. When fields are declared in_118 // Java classes, they aren't fully initialized until the constructor runs._118 // Note the format of the string representation of `Tuple2`'s constructor._118 {_118 fv = cw.visitField(0, "tup", "Lscala/Tuple2;",_118 "Lscala/Tuple2<Ljava/lang/String;Ljava/lang/Integer;>;", null);_118 fv.visitEnd();_118 }_118_118 // Generate the public constructor named <init> by convention_118 {_118 mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);_118 mv.visitCode();_118_118 // Put `this` on the stack_118 mv.visitVarInsn(ALOAD, 0);_118_118 // Invoke constructor on `this`. Note that it takes no params and returns_118 // void, and it consumes the `this` that we put on the stack above._118 mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);_118_118 // Put `this` on the stack again_118 mv.visitVarInsn(ALOAD, 0);_118_118 //_118 // Start `tup` initialization {_118 //_118_118 // Put a new scala.Tuple2 on the stack then duplicate the object reference_118 // on top of the stack. Note that since it is an object reference and not_118 // the object itself, any mutation we do to the object will be reflected in_118 // any references to that object on the stack._118 mv.visitTypeInsn(NEW, "scala/Tuple2");_118 mv.visitInsn(DUP);_118_118 // Push constant "foo" from the constant pool onto the stack_118 mv.visitLdcInsn("foo");_118_118 // Load the int constant 2 onto the stack_118 mv.visitInsn(ICONST_2);_118_118 // Autobox the int in a java.lang.Integer. This pops the int off the stack_118 // and replaces it with the Integer._118 mv.visitMethodInsn(INVOKESTATIC, "java/lang/Integer", "valueOf",_118 "(I)Ljava/lang/Integer;", false);_118_118 // At this point our stack looks like:_118 // Integer.valueOf(2)_118 // "foo"_118 // Tuple2 (reference)_118 // Tuple2 (reference)_118_118 // Initialize the Tuple2. Looking at the signature, we can see it takes_118 // two java.lang.Objects (instead of a String and Integer, due to type_118 // erasure), which it will consume from the stack in addition_118 // to one of the Tuple2 references._118 mv.visitMethodInsn(INVOKESPECIAL, "scala/Tuple2", "<init>",_118 "(Ljava/lang/Object;Ljava/lang/Object;)V", false);_118_118 // Finally, we store our Tuple2 value in the `tup` field. This consumes_118 // the second copy of the Tuple2 reference we had on the stack._118 mv.visitFieldInsn(PUTFIELD, "TupleFromJava", "tup", "Lscala/Tuple2;");_118_118 //_118 // End `tup` initialization }_118 //_118_118 // Constructors always return void because constructors should only_118 // initialize the object and do nothing else._118 mv.visitInsn(RETURN);_118_118 // Sets the max stack size and max number of local vars. This can be_118 // calculated automatically for you if you use COMPUTE_FRAMES or COMPUTE_MAXS_118 // in the ClassWriter constructor._118 mv.visitMaxs(5, 1);_118 mv.visitEnd();_118 }_118_118 // Create a public method `getTup` which takes no args and returns a_118 // scala.Tuple2_118 {_118 mv = cw.visitMethod(ACC_PUBLIC, "getTup", "()Lscala/Tuple2;",_118 "()Lscala/Tuple2<Ljava/lang/String;Ljava/lang/Integer;>;", null);_118 mv.visitCode();_118 // Load `this` onto the stack_118 mv.visitVarInsn(ALOAD, 0);_118 // Load a reference to the `tup` field on `this` onto the stack, consuming_118 // `this` in the process_118 mv.visitFieldInsn(GETFIELD, "TupleFromJava", "tup", "Lscala/Tuple2;");_118 // Return the reference to the `tup` field_118 mv.visitInsn(ARETURN);_118 // We only needed a max stack size of 1 and maximum of 1 local vars_118 mv.visitMaxs(1, 1);_118 mv.visitEnd();_118 }_118_118 // Finish writing the class_118 cw.visitEnd();_118_118 return cw.toByteArray();_118_118 }_118}
Not too bad. ASMifier helpfully wraps each logical chunk in blocks. The first
block creates the tup
field. The second block is our public
constructor. It calls super, which invokes the constructor on the
java.lang.Object
superclass, then initializes the
tup
field, and finally returns void. The third block
is the getTup
getter.
NB: If you're having trouble following this, I recommend generating some ASM code with ASMifier, then annotating it yourself. It really helps to internalize the JVM bytecodes and how to work on a stack machine.
Inside a method, the parameters can be referenced by corresponding local variable indices. For example:
_10def add(x: Int, y: Int)
In this case, x
is available at 1
and
y
is available at 2
. To load and use
these, we would use visitVarInsn
which visits a local
variable instruction. Using ASM, this is how we'd add x
and
y
and store the result in local variable
3
:
_10mv.visitVarInsn(ILOAD, 1); // Load x onto the stack_10mv.visitVarInsn(ILOAD, 2); // Load y onto the stack_10mv.visitInsn(IADD); // Pop two values off the stack, add them,_10 // then put the result back on the stack_10mv.visitVarInsn(ISTORE, 3); // Pop a value off the stack and store_10 // it in local variable 3
When you generate ASM using ASMifier it can generate all the labels and local var mappings, which is necessary information for debuggers to show you the correct names of the local variables in a given stack, since in the JVM indices are used instead of names. When writing by hand, you could opt to not write these instructions, or add them later if you need them.
Compiled queries with ASM
Let's use this knowledge to compile the query we originally interpreted. To start, let's write a fast but static version of the query so we can quickly figure out which parts need to be made dynamic. When using ASM it's a good idea to distil the essense of which part needs to be made dynamic so the generator code ends up being as small as possible. Since ASM is hard to read, write, and debug, this is very important.
Let's perform the same projection and filtering we used before, but this time without the interpretation.
_10object StaticQuery {_10_10 def query(db: Seq[(String, Int, String)]) = {_10 db.flatMap { case (name, birthYear, dissertation) =>_10 if (birthYear < 1910 && name != null) Some((name, dissertation))_10 else None_10 }_10 }_10_10}
This should be much faster than our interpreted query because it's running a
simple conditional instead of walking and evaluating a
FilterExpr
on every row.
Let's construct a quick benchmark using ScalaMeter to verify our assumptions.
_21object QueryBenchmark extends PerformanceTest.Quickbenchmark {_21_21 val birthYears = Gen.range("birthYears")(9990, 10000, 1)_21_21 val records = for {_21 birthYear <- birthYears_21 } yield (0 until birthYear).map(("name", _, "diss"))_21_21 performance of "Querying" in {_21 measure method "StaticQuery" in {_21 using (records) in { StaticQuery.query(_) }_21 }_21_21 measure method "InterpretedQuery" in {_21 using (records) in {_21 InterpretedQuery.query(_, Main.projections, Main.filterExpr)_21 }_21 }_21 }_21_21}
Results from the last result of each measurement:
StaticQuery
: 1.329709msInterpretedQuery
: 6.949921ms
The static query is between 5 and 6 times faster than interpreting.
Let's rewrite StaticQuery
in Java and use it as a template
for compiling queries. There are at least two reasons why I significantly
dislike running ASMifier against Scala classes:
- It generates a gnarly blob of unreadable bytes for the ScalaSignature (which is apparently used to store Scala-specific bits in class files and is required for reflection and for compiling against).
- Scala objects and methods get split into separate class files when they're compiled, making it hard to stitch together the results with multiple ASMifier runs.
_26import scala.Tuple3;_26import scala.collection.Seq;_26import scala.collection.mutable.ArrayBuffer;_26import scala.collection.Iterator;_26_26public class StaticJavaQuery {_26_26 public static Seq<Tuple3<String, Integer, String>> query(_26 Seq<Tuple3<String, Integer, String>> db) {_26 Iterator<Tuple3<String, Integer, String>> iter = db.iterator();_26 ArrayBuffer<Tuple2<String, String>> acc =_26 new ArrayBuffer<Tuple2<String, String>>();_26 while (iter.hasNext()) {_26 Tuple3<String, Integer, String> row = iter.next();_26 Integer birthYear = row._2();_26 if (birthYear.intValue() < 1910 && row._1() != null) {_26 acc.$plus$eq(new Tuple2<String, String>(_26 row._1(),_26 row._3()_26 ));_26 }_26 }_26 return acc;_26 }_26_26}
It's not pretty, but it works, and it happens to be even faster than the static Scala query. We'll start by feeding it to ASMifier, convert the output to Scala, then work on making the result dynamic. Converted output, heavily annotated:
_171import org.objectweb.asm._, Opcodes.__171import Database.FilterExpr_171_171object CompiledQueryGen extends Opcodes {_171_171 def generate: Array[Byte] = {_171_171 val cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES)_171 var mv: MethodVisitor = null_171_171 cw.visit(52, ACC_PUBLIC + ACC_SUPER, "CompiledQuery", null,_171 "java/lang/Object", null)_171_171 // Constructor_171 {_171 mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null)_171 mv.visitCode()_171 mv.visitVarInsn(ALOAD, 0)_171 mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false)_171 mv.visitInsn(RETURN)_171 mv.visitMaxs(1, 1)_171 mv.visitEnd()_171 }_171_171 // Static query method_171 {_171 mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "query", "(Lscala/collection/Seq)Lscala/collection/Seq", "(Lscala/collection/Seq<Lscala/Tuple3<Ljava/lang/StringLjava/lang/IntegerLjava/lang/String>>)Lscala/collection/Seq<Lscala/Tuple3<Ljava/lang/StringLjava/lang/IntegerLjava/lang/String>>", null)_171 mv.visitCode()_171_171 // Load the `db` argument onto the stack_171 mv.visitVarInsn(ALOAD, 0)_171 // Invoke the `iterator` method on db, putting the iterator on the stack_171 mv.visitMethodInsn(INVOKEINTERFACE, "scala/collection/Seq", "iterator", "()Lscala/collection/Iterator", true)_171 // Store the iterator object at index 1_171 mv.visitVarInsn(ASTORE, 1)_171_171 // Stack size = 0_171_171 // Instantiate a new ArrayBuffer `acc` {_171 mv.visitTypeInsn(NEW, "scala/collection/mutable/ArrayBuffer")_171 // Duplicate the reference to it on the stack_171 mv.visitInsn(DUP)_171 // Initialize the `acc` ArrayBuffer_171 mv.visitMethodInsn(INVOKESPECIAL, "scala/collection/mutable/ArrayBuffer", "<init>", "()V", false)_171 // Store the ArrayBuffer at index 2_171 mv.visitVarInsn(ASTORE, 2)_171_171 // Stack size = 0_171_171 // A label is a point we can jump to with GOTO-style instructions. l0_171 // marks the start of the while loop. The point at which the label is_171 // visited represents its position and l0 is visited immediately._171 // while (...) {_171 val l0 = new Label_171 mv.visitLabel(l0)_171_171 // Check the while condition_171 // Load the iterator onto the stack from index 1_171 mv.visitVarInsn(ALOAD, 1)_171 // Call `hasNext` on the iterator, storing the boolean result on the_171 // stack. The JVM stores boolean as int: 0 is false, 1 is true._171 mv.visitMethodInsn(INVOKEINTERFACE, "scala/collection/Iterator",_171 "hasNext", "()Z", true)_171_171 // Stack size = 1, hasNext boolean_171_171 // Create another jump location for the end of the loop. l1 isn't visited_171 // until later at the end of the loop body but we need to create the label_171 // here in order to reference it in `IFEQ`._171 val l1 = new Label_171 // A jump instruction with IFEQ ("if equals") checks the current value on_171 // the stack. If it's 0 (false) it jumps to the label, thus ending our_171 // while loop._171 mv.visitJumpInsn(IFEQ, l1)_171_171 // Stack size = 0_171_171 // Load iterator onto the stack again_171 mv.visitVarInsn(ALOAD, 1)_171 // Obtain the `row` value from the iterator_171 mv.visitMethodInsn(INVOKEINTERFACE, "scala/collection/Iterator", "next", "()Ljava/lang/Object", true)_171 // Ensure the value is of expected type, Tuple3. This instruction pops a_171 // value off the stack, checks it, then puts it back on the stack._171 mv.visitTypeInsn(CHECKCAST, "scala/Tuple3")_171 // Store the row Tuple3 at local variable index 3_171 mv.visitVarInsn(ASTORE, 3)_171 // Load it again_171 mv.visitVarInsn(ALOAD, 3)_171_171 // Stack size = 1, row Tuple3_171_171 // Invoke the `_2` method on the row to get the birthYear_171 mv.visitMethodInsn(INVOKEVIRTUAL, "scala/Tuple3", "_2", "()Ljava/lang/Object", false)_171 // Ensure the expected type, Integer_171 mv.visitTypeInsn(CHECKCAST, "java/lang/Integer")_171 // Store birthYear at local var 4_171 mv.visitVarInsn(ASTORE, 4)_171 // Load birthYear from local var 4_171 mv.visitVarInsn(ALOAD, 4)_171 // Invoke the `intValue` method on birthYear_171 mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Integer", "intValue", "()I", false)_171 // Push a short constant on to the stack_171 mv.visitIntInsn(SIPUSH, 1910)_171_171 // Stack size = 2, birthYear int, 1910 short_171_171 // Any time we need to branch in some way, we need labels and jump_171 // instructions. l2 marks the end of the filtering if statement, allowing_171 //_171 // us to jump over the body._171 val l2 = new Label()_171_171 // Jump instructions are always the inverse predicate because if it_171 // evaluates to true then it jumps, skipping the body of the if block._171 // IF_ICMPGE is short for "if int compare greater than or equal", so:_171 // If value1 >= value2 then jump to l2, where_171 // value1 = birthYear_171 // value2 = 1910_171 mv.visitJumpInsn(IF_ICMPGE, l2)_171 // That's the first half of the if predicate. Now we check the other half._171_171 // Load the row Tuple3_171 mv.visitVarInsn(ALOAD, 3)_171 // Invoke the `_1` method on the row to get the name String_171 mv.visitMethodInsn(INVOKEVIRTUAL, "scala/Tuple3", "_1", "()Ljava/lang/Object", false)_171 // This condition is much simpler and the JVM even has an instruction to_171 // check for null. If the name String is null, jump to l2._171 mv.visitJumpInsn(IFNULL, l2)_171_171 // Body of the if block {_171_171 // Load the `acc` ArrayBuffer_171 mv.visitVarInsn(ALOAD, 2)_171 // Load the `row` Tuple3_171 mv.visitVarInsn(ALOAD, 3)_171 // Invoke the `$plus$eq` method on `acc` which mutates it, appending the_171 // `row` Tuple3, and stores the result (which is simply itself) on the_171 // stack._171 mv.visitMethodInsn(INVOKEVIRTUAL, "scala/collection/mutable/ArrayBuffer", "$plus$eq", "(Ljava/lang/Object)Lscala/collection/mutable/ArrayBuffer", false)_171 // Discard the last item on the stack since we no longer need it._171 mv.visitInsn(POP)_171_171 // }_171_171 // Mark the end of the if block_171 mv.visitLabel(l2)_171_171 // Jump back to the start of the while loop_171 mv.visitJumpInsn(GOTO, l0)_171 // Mark the end of the while loop_171 mv.visitLabel(l1)_171 // } // end while_171_171 // Load the acc Tuple3_171 mv.visitVarInsn(ALOAD, 2)_171 // Return the object on the stack_171 mv.visitInsn(ARETURN)_171 // Compute the max stack size and number of local vars (computed_171 // automatically for us via COMPUTE_FRAMES)_171 mv.visitMaxs(0, 0)_171 // End the method_171 mv.visitEnd()_171 }_171_171 // End the class_171 cw.visitEnd()_171_171 // Return the bytes representing a generated classfile_171 cw.toByteArray_171 }_171}
Since we're using ClassWriter.COMPUTE_FRAMES
I was able to
remove all the visitFrame
calls that ASMifier generated. I
also deleted the generated FieldVisitor
and
AnnotationVisitor
as they were both unused. I used 0
for
all arguments to visitMaxs
as
COMPUTE_FRAMES
implies COMPUTE_MAXS
, which still requires
calls to visitMaxs
but ignores the arguments.
Scala Quasiquotes
This part of the post is not yet written. The intent was to explore Scala Quasiquotes facilities for codegen.
Next steps
An interesting direction to take this, now that we have a foundation for dynamically compiling queries, would be to add a SQL interface. Apache Calcite is well-suited to do just that.