dongjoon-hyun commented on code in PR #59042:
URL: https://github.com/apache/spark/pull/59042#discussion_r4108398490


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodeGenerator.scala:
##########
@@ -1248,7 +1248,67 @@ class CodegenContext extends Logging {
         makeSplitFunction,
         foldFunctions)
 
-      foldFunctions(outerClassFunctionCalls ++ innerClassFunctionCalls)
+      foldFunctions(groupFunctionCalls(
+        (outerClassFunctionCalls ++ innerClassFunctionCalls).toSeq,
+        func,
+        arguments,
+        returnType,
+        makeSplitFunction,
+        foldFunctions))
+    }
+  }
+
+  /**
+   * The calls to the functions `splitExpressions` split out, grouped into 
functions of their own
+   * where there are too many for the calling method to be JIT-compiled, level 
by level.
+   *
+   * The calls are what is left in the calling method, one per block of code, 
and a `CASE WHEN`
+   * of a thousand branches has hundreds of blocks: their calls alone take the 
caller past the
+   * 8000 bytes HotSpot compiles 
(`CodeGenerator.DEFAULT_JVM_HUGE_METHOD_LIMIT`), so the method
+   * every row goes through runs interpreted. A call and its fold compile to 
at most about 20 bytes
+   * plus 2 for each argument - the invocation, a load per argument, and the 
fold's store, test
+   * and branch - so the calls are left as they are while they fit in half of 
that limit, the rest
+   * being the caller's own code, and grouped otherwise. The groups are small, 
of
+   * `CodeGenerator.SPLIT_CALLS_PER_GROUP` calls, so that C2 can inline the 
split functions into
+   * each group within its inlining budget; a group of hundreds of calls stays 
compilable but has
+   * most of its calls left out of inlining, and runs slower. A group is 
folded with
+   * `foldFunctions` and wrapped with `makeSplitFunction`, which is what
+   * `generateInnerClassesFunctionCalls` does with the functions of one inner 
class, so every
+   * caller of `splitExpressions` supports it.
+   */
+  /**
+   * How many calls to split functions taking `arguments` fit in one method 
with room to spare
+   * below the 8000 bytes HotSpot compiles; see `groupFunctionCalls`.
+   */
+  private def splitCallsPerMethod(arguments: Seq[(String, String)]): Int = 
math.max(2,

Review Comment:
   This budget assumes a single `splitExpressions` call owns half of the 
8000-byte limit. When one method invokes `splitExpressions` multiple times 
(e.g. `ConcatWs` calls it three times in the same method), the combined calls 
can still push the caller past the limit. It's probably rare, but could we 
document this limitation here?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodeGenerator.scala:
##########
@@ -1248,7 +1248,67 @@ class CodegenContext extends Logging {
         makeSplitFunction,
         foldFunctions)
 
-      foldFunctions(outerClassFunctionCalls ++ innerClassFunctionCalls)
+      foldFunctions(groupFunctionCalls(
+        (outerClassFunctionCalls ++ innerClassFunctionCalls).toSeq,
+        func,
+        arguments,
+        returnType,
+        makeSplitFunction,
+        foldFunctions))
+    }
+  }
+
+  /**
+   * The calls to the functions `splitExpressions` split out, grouped into 
functions of their own

Review Comment:
   This Scaladoc is immediately followed by the Scaladoc of 
`splitCallsPerMethod`, so it is orphaned and not attached to any member. Could 
you move it right above `private def groupFunctionCalls(`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodeGenerator.scala:
##########
@@ -1314,13 +1374,28 @@ class CodegenContext extends Logging {
 
     val argDefinitionString = arguments.map { case (t, name) => s"$t $name" 
}.mkString(", ")
     val argInvocationString = arguments.map(_._2).mkString(", ")
+    val callsPerMethod = splitCallsPerMethod(arguments)
 
     innerClassToFunctions.flatMap {
       case ((innerClassName, innerClassInstance), innerClassFunctions) =>
         // for performance reasons, the functions are prepended, instead of 
appended,
         // thus here they are in reversed order
         val orderedFunctions = innerClassFunctions.reverse
-        if (orderedFunctions.size > MERGE_SPLIT_METHODS_THRESHOLD) {
+        if (orderedFunctions.size > callsPerMethod) {
+          // Too many calls for one method to stay JIT-compilable: one merged 
method per part,
+          // whose calls the caller groups further if there are still too many 
of them.
+          orderedFunctions.grouped(callsPerMethod).zipWithIndex.map { case 
(part, i) =>

Review Comment:
   Each `_part` method here holds `callsPerMethod` calls (~166-200 with 1-2 
arguments). However, the PR description says groups of ~180 calls make C2 
refuse most inlining and run ~35% slower, which is why `SPLIT_CALLS_PER_GROUP` 
is 8. So this path re-creates the size the PR is trying to avoid. Since merging 
inside the inner class is for the outer class' constant pool, how about 
splitting into parts of `SPLIT_CALLS_PER_GROUP` here and letting 
`groupFunctionCalls` handle further levels? If the current size is intentional, 
could you add a comment explaining why?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodeGenerator.scala:
##########
@@ -1248,7 +1248,67 @@ class CodegenContext extends Logging {
         makeSplitFunction,
         foldFunctions)
 
-      foldFunctions(outerClassFunctionCalls ++ innerClassFunctionCalls)
+      foldFunctions(groupFunctionCalls(
+        (outerClassFunctionCalls ++ innerClassFunctionCalls).toSeq,
+        func,
+        arguments,
+        returnType,
+        makeSplitFunction,
+        foldFunctions))
+    }
+  }
+
+  /**
+   * The calls to the functions `splitExpressions` split out, grouped into 
functions of their own
+   * where there are too many for the calling method to be JIT-compiled, level 
by level.
+   *
+   * The calls are what is left in the calling method, one per block of code, 
and a `CASE WHEN`
+   * of a thousand branches has hundreds of blocks: their calls alone take the 
caller past the
+   * 8000 bytes HotSpot compiles 
(`CodeGenerator.DEFAULT_JVM_HUGE_METHOD_LIMIT`), so the method
+   * every row goes through runs interpreted. A call and its fold compile to 
at most about 20 bytes
+   * plus 2 for each argument - the invocation, a load per argument, and the 
fold's store, test
+   * and branch - so the calls are left as they are while they fit in half of 
that limit, the rest
+   * being the caller's own code, and grouped otherwise. The groups are small, 
of
+   * `CodeGenerator.SPLIT_CALLS_PER_GROUP` calls, so that C2 can inline the 
split functions into
+   * each group within its inlining budget; a group of hundreds of calls stays 
compilable but has
+   * most of its calls left out of inlining, and runs slower. A group is 
folded with
+   * `foldFunctions` and wrapped with `makeSplitFunction`, which is what
+   * `generateInnerClassesFunctionCalls` does with the functions of one inner 
class, so every
+   * caller of `splitExpressions` supports it.
+   */
+  /**
+   * How many calls to split functions taking `arguments` fit in one method 
with room to spare
+   * below the 8000 bytes HotSpot compiles; see `groupFunctionCalls`.
+   */
+  private def splitCallsPerMethod(arguments: Seq[(String, String)]): Int = 
math.max(2,
+    DEFAULT_JVM_HUGE_METHOD_LIMIT / 2 / (MAX_BYTES_PER_SPLIT_CALL + 2 * 
arguments.length))
+
+  private def groupFunctionCalls(
+      calls: Seq[String],
+      funcName: String,
+      arguments: Seq[(String, String)],
+      returnType: String,
+      makeSplitFunction: String => String,
+      foldFunctions: Seq[String] => String,
+      level: Int = 0): Seq[String] = {
+    val callsPerMethod = splitCallsPerMethod(arguments)
+    if (calls.length <= callsPerMethod) {
+      calls
+    } else {
+      val argDefinitionString = arguments.map { case (t, name) => s"$t $name" 
}.mkString(", ")
+      val argInvocationString = arguments.map(_._2).mkString(", ")
+      val groupCalls = calls.grouped(SPLIT_CALLS_PER_GROUP).zipWithIndex.map { 
case (group, i) =>
+        val name = s"${funcName}_group${level}_$i"
+        val code =
+          s"""
+             |private $returnType $name($argDefinitionString) {
+             |  ${makeSplitFunction(foldFunctions(group))}
+             |}
+           """.stripMargin
+        s"${addNewFunction(name, code)}($argInvocationString)"

Review Comment:
   When the outer class is past `GENERATED_CLASS_SIZE_THRESHOLD`, 
`addNewFunction` can place a group function into an inner class. Then a group 
in inner class A calls private methods of inner class B 
(`innerInstance.f(...)`) or private split functions of the outer class. This is 
legal Java, but I'm not sure this sibling-nested-class private access pattern 
was exercised with Janino before. Could you confirm that one of the new tests 
(e.g. the 30000-wide hash test) actually goes through this path?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/CodeGenerationSuite.scala:
##########
@@ -102,6 +102,91 @@ class CodeGenerationSuite extends SparkFunSuite with 
ExpressionEvalHelper {
     assert(actual.head == cases)
   }
 
+  /**
+   * Runs `body` and fails if the code it generates has a method past the 8000 
bytes HotSpot
+   * compiles, which `CodeGenerator` logs as it compiles the class.
+   */
+  private def assertJitCompilable(body: => Unit): Unit = {
+    val appender = new LogAppender("methods too long to be JIT compiled")
+    withLogAppender(appender,
+        loggerNames = Seq(classOf[CodeGenerator[_, _]].getName), level = 
Some(Level.INFO)) {
+      body
+    }
+    val tooLong = appender.loggingEvents.map(_.getMessage.getFormattedMessage)
+      .filter(_.contains("too long to be JIT compiled"))
+    assert(tooLong.isEmpty, tooLong.mkString("\n"))
+  }
+
+  // The tests below pin SPARK-59783: an expression split into hundreds of 
functions left one call
+  // per function in the method holding them, and the calls alone took that 
method past the
+  // 8000 bytes HotSpot compiles, so every row ran it interpreted. Each test 
covers one caller of
+  // `splitExpressions`, with as many children as it takes for that method to 
cross the limit
+  // without the grouping: fewer where one child generates more code.
+
+  test("SPARK-59783: the calls to the functions CASE WHEN splits into are 
JIT-compilable") {
+    val wide = 3000
+    val input = BoundReference(0, IntegerType, nullable = false)
+    val expression = CaseWhen(
+      (1 to wide).map(k => (EqualTo(input, Literal(k)), Multiply(input, 
Literal(k)))), Literal(0))
+    assertJitCompilable {
+      val projection = GenerateMutableProjection.generate(Seq(expression))
+      Seq(7 -> 49, wide -> wide * wide, wide + 1 -> 0).foreach { case (in, 
out) =>
+        assert(projection(new GenericInternalRow(Array[Any](in))).getInt(0) == 
out)
+      }
+    }
+  }
+
+  test("SPARK-59783: the calls to the functions COALESCE splits into are 
JIT-compilable") {
+    val wide = 10000
+    val expression = Coalesce((0 until wide).map(i => BoundReference(i, 
IntegerType, true)))
+    assertJitCompilable {
+      val projection = GenerateMutableProjection.generate(Seq(expression))
+      val values = new Array[Any](wide)
+      assert(projection(new GenericInternalRow(values)).isNullAt(0))
+      values(wide - 1) = 42
+      assert(projection(new GenericInternalRow(values)).getInt(0) == 42)
+    }
+  }
+
+  test("SPARK-59783: the calls to the functions IN splits into are 
JIT-compilable") {
+    val wide = 10000
+    val input = BoundReference(0, IntegerType, nullable = false)
+    val expression = In(input, (1 to wide).map(k => Literal(k * 2)))
+    assertJitCompilable {
+      val projection = GenerateMutableProjection.generate(Seq(expression))
+      Seq(2 -> true, wide * 2 -> true, 3 -> false).foreach { case (in, out) =>
+        assert(projection(new 
GenericInternalRow(Array[Any](in))).getBoolean(0) == out)
+      }
+    }
+  }
+
+  test("SPARK-59783: the calls to the functions a hash splits into are 
JIT-compilable") {

Review Comment:
   These tests compile very wide expressions with Janino (30000-wide hash 
twice, 3000-wide ordering and `CASE WHEN`). Could you share how long the new 
tests take? If they are slow, reducing the width to just past the threshold 
might help.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodeGenerator.scala:
##########
@@ -1740,6 +1815,19 @@ object CodeGenerator extends Logging {
   // method which is going to be called by the outer class instead of the many 
small ones
   final val MERGE_SPLIT_METHODS_THRESHOLD = 3
 
+  /**
+   * The most bytecode a call to a split function and its fold take in the 
calling method,
+   * besides a load per argument; see `CodegenContext.groupFunctionCalls`.
+   */
+  final val MAX_BYTES_PER_SPLIT_CALL = 20

Review Comment:
   Is the upper bound of 20 bytes verified with `javap`? A call to an 
inner-class function additionally needs `aload_0` + `getfield` for the 
instance, and a fold like `CaseWhen`'s (store, compare, branch) gets close to 
20 bytes. The half-limit margin probably covers it, but it would be good to 
confirm.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to