-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-35141][SQL] Support two level of hash maps for final hash aggregation #32242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
539720d
c9d09be
917e7bb
67d4cd7
965a35c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,7 @@ import java.util.concurrent.TimeUnit._ | |
| import scala.collection.mutable | ||
|
|
||
| import org.apache.spark.TaskContext | ||
| import org.apache.spark.memory.{SparkOutOfMemoryError, TaskMemoryManager} | ||
| import org.apache.spark.memory.SparkOutOfMemoryError | ||
| import org.apache.spark.rdd.RDD | ||
| import org.apache.spark.sql.catalyst.InternalRow | ||
| import org.apache.spark.sql.catalyst.expressions._ | ||
|
|
@@ -128,6 +128,16 @@ case class HashAggregateExec( | |
| // all the mode of aggregate expressions | ||
| private val modes = aggregateExpressions.map(_.mode).distinct | ||
|
|
||
| // This is for testing final aggregate with number-of-rows-based fall back as specified in | ||
| // `testFallbackStartsAt`. In this scenario, there might be same keys exist in both fast and | ||
| // regular hash map. So the aggregation buffers from both maps need to be merged together | ||
| // to avoid correctness issue. | ||
| // | ||
| // This scenario only happens in unit test with number-of-rows-based fall back. | ||
| // There should not be same keys in both maps with size-based fall back in production. | ||
| private val isTestFinalAggregateWithFallback: Boolean = testFallbackStartsAt.isDefined && | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. instead of merging the hash maps, shall we fix the number-of-rows-based fallback to make it similar to the size-based fallback?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cloud-fan - I was thinking the same way too. I found it's quite hard to fix the fallback logic. I tried the approach to add a find(key): Boolean method in generated first level map, and to first check if key already exists in first level map. But I found other case like the key can be put into second level map, later added to first level map as well (fallback row counter reset to 0 case).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't touched this part of the code for a while, can you briefly introduce how size-based fallback work?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cloud-fan - sure. This is how number-of-rows-based fallback works. With an internal config Suppose Then the generated code per input row (aggregate the row into hash map) looks like: Example generated code is Line 187-232 in https://gist.github.com/c21/d0f704c0a33c24ec05387ff4df438bff . I tried to add a method But I later found the case as I mentioned above:
We can further add code like this: But it introduces more ad-hoc change and looks pretty ugly with a lot of code needs to be moved.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cloud-fan - sorry, I overlooked your question, you are asking how size-based fallback works. Size-based fallback works as:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems to me that the major issue is we use a single counter to control both the fast and regular hash map fallback. My first thought is to add a dedicated counter for the fast hash map fallback, then I realized that the fast hash map has a capacity property. Can we simply set the capacity to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cloud-fan - maybe I am missing something but not sure how these two solutions fix the problem.
Counter example:
Counter example:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My idea is to simulate the size-based fallback: "no space" -> "reach the capacity/limit"
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cloud-fan - updated per offline discussion. Changed the first level fallback by restricting first level map capacity.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also just FYI - I updated the generated code in PR description for checking if needed. |
||
| (modes.contains(Final) || modes.contains(Complete)) | ||
|
|
||
| override def usedInputs: AttributeSet = inputSet | ||
|
|
||
| override def supportCodegen: Boolean = { | ||
|
|
@@ -435,8 +445,8 @@ case class HashAggregateExec( | |
| ) | ||
| } | ||
|
|
||
| def getTaskMemoryManager(): TaskMemoryManager = { | ||
| TaskContext.get().taskMemoryManager() | ||
| def getTaskContext(): TaskContext = { | ||
| TaskContext.get() | ||
| } | ||
|
|
||
| def getEmptyAggregationBuffer(): InternalRow = { | ||
|
|
@@ -537,6 +547,34 @@ case class HashAggregateExec( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Called by generated Java class to finish merge the fast hash map into regular map. | ||
| * This is used for testing final aggregate only. | ||
| */ | ||
| def mergeFastHashMapForTest( | ||
| fastHashMapRowIter: KVIterator[UnsafeRow, UnsafeRow], | ||
| regularHashMap: UnsafeFixedWidthAggregationMap): Unit = { | ||
|
|
||
| // Create a MutableProjection to merge the buffers of same key together | ||
| val mergeExpr = declFunctions.flatMap(_.mergeExpressions) | ||
| val mergeProjection = MutableProjection.create( | ||
| mergeExpr, | ||
| aggregateBufferAttributes ++ declFunctions.flatMap(_.inputAggBufferAttributes)) | ||
| val joinedRow = new JoinedRow() | ||
|
|
||
| while (fastHashMapRowIter.next()) { | ||
| val key = fastHashMapRowIter.getKey | ||
| val fastMapBuffer = fastHashMapRowIter.getValue | ||
| val regularMapBuffer = regularHashMap.getAggregationBufferFromUnsafeRow(key) | ||
|
|
||
| // Merge the aggregation buffer of fast hash map, into the buffer with same key of | ||
| // regular map | ||
| mergeProjection.target(regularMapBuffer) | ||
| mergeProjection(joinedRow(regularMapBuffer, fastMapBuffer)) | ||
| } | ||
| fastHashMapRowIter.close() | ||
| } | ||
|
|
||
| /** | ||
| * Generate the code for output. | ||
| * @return function name for the result code. | ||
|
|
@@ -647,7 +685,7 @@ case class HashAggregateExec( | |
| (groupingKeySchema ++ bufferSchema).forall(f => CodeGenerator.isPrimitiveType(f.dataType) || | ||
| f.dataType.isInstanceOf[DecimalType] || f.dataType.isInstanceOf[StringType] || | ||
| f.dataType.isInstanceOf[CalendarIntervalType]) && | ||
| bufferSchema.nonEmpty && modes.forall(mode => mode == Partial || mode == PartialMerge) | ||
| bufferSchema.nonEmpty | ||
|
|
||
| // For vectorized hash map, We do not support byte array based decimal type for aggregate values | ||
| // as ColumnVector.putDecimal for high-precision decimals doesn't currently support in-place | ||
|
|
@@ -663,7 +701,7 @@ case class HashAggregateExec( | |
|
|
||
| private def enableTwoLevelHashMap(ctx: CodegenContext): Unit = { | ||
| if (!checkIfFastHashMapSupported(ctx)) { | ||
| if (modes.forall(mode => mode == Partial || mode == PartialMerge) && !Utils.isTesting) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. last question: can we search the commit history and figure out why we didn't enable the fast hash map in the final aggregate? It seems we did it on purpose.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cloud-fan - I was wondering at first place before making this PR as well. The decision to only support partial aggregate is made when the first level hash map was introduced (#12345 and #14176), and never changed afterwards. I checked with @sameeragarwal before making this PR. He told me there is no fundamental reason to not support final aggregate. Just for documentation, I asked him why we don't support nested type (array/map/struct) as key type for fast hash map. He told me the reason was the size of keys might be too large for long array/map/struct, so the size of fast hash map may not fit in cache and lose the benefit. |
||
| if (!Utils.isTesting) { | ||
| logInfo(s"${SQLConf.ENABLE_TWOLEVEL_AGG_MAP.key} is set to true, but" | ||
| + " current version of codegened fast hashmap does not support this aggregate.") | ||
| } | ||
|
|
@@ -717,7 +755,7 @@ case class HashAggregateExec( | |
| "org.apache.spark.unsafe.KVIterator<UnsafeRow, UnsafeRow>", | ||
| "fastHashMapIter", forceInline = true) | ||
| val create = s"$fastHashMapTerm = new $fastHashMapClassName(" + | ||
| s"$thisPlan.getTaskMemoryManager(), $thisPlan.getEmptyAggregationBuffer());" | ||
| s"$thisPlan.getTaskContext(), $thisPlan.getEmptyAggregationBuffer());" | ||
| (iter, create) | ||
| } | ||
| } else ("", "") | ||
|
|
@@ -740,8 +778,13 @@ case class HashAggregateExec( | |
| val finishRegularHashMap = s"$iterTerm = $thisPlan.finishAggregate(" + | ||
| s"$hashMapTerm, $sorterTerm, $peakMemory, $spillSize, $avgHashProbe);" | ||
| val finishHashMap = if (isFastHashMapEnabled) { | ||
| val finishFastHashMap = if (isTestFinalAggregateWithFallback) { | ||
| s"$thisPlan.mergeFastHashMapForTest($fastHashMapTerm.rowIterator(), $hashMapTerm);" | ||
| } else { | ||
| s"$iterTermForFastHashMap = $fastHashMapTerm.rowIterator();" | ||
| } | ||
| s""" | ||
| |$iterTermForFastHashMap = $fastHashMapTerm.rowIterator(); | ||
| |$finishFastHashMap | ||
| |$finishRegularHashMap | ||
| """.stripMargin | ||
| } else { | ||
|
|
@@ -761,8 +804,10 @@ case class HashAggregateExec( | |
| val bufferTerm = ctx.freshName("aggBuffer") | ||
| val outputFunc = generateResultFunction(ctx) | ||
|
|
||
| val limitNotReachedCondition = limitNotReachedCond | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding the limit early termination for first level map as well. This is needed to fix test failure |
||
|
|
||
| def outputFromFastHashMap: String = { | ||
| if (isFastHashMapEnabled) { | ||
| if (isFastHashMapEnabled && !isTestFinalAggregateWithFallback) { | ||
| if (isVectorizedHashMapEnabled) { | ||
| outputFromVectorizedMap | ||
| } else { | ||
|
|
@@ -773,7 +818,7 @@ case class HashAggregateExec( | |
|
|
||
| def outputFromRowBasedMap: String = { | ||
| s""" | ||
| |while ($iterTermForFastHashMap.next()) { | ||
| |while ($limitNotReachedCondition $iterTermForFastHashMap.next()) { | ||
| | UnsafeRow $keyTerm = (UnsafeRow) $iterTermForFastHashMap.getKey(); | ||
| | UnsafeRow $bufferTerm = (UnsafeRow) $iterTermForFastHashMap.getValue(); | ||
| | $outputFunc($keyTerm, $bufferTerm); | ||
|
|
@@ -798,7 +843,7 @@ case class HashAggregateExec( | |
| BoundReference(groupingKeySchema.length + i, attr.dataType, attr.nullable) | ||
| }) | ||
| s""" | ||
| |while ($iterTermForFastHashMap.hasNext()) { | ||
| |while ($limitNotReachedCondition $iterTermForFastHashMap.hasNext()) { | ||
| | InternalRow $row = (InternalRow) $iterTermForFastHashMap.next(); | ||
| | ${generateKeyRow.code} | ||
| | ${generateBufferRow.code} | ||
|
|
@@ -813,7 +858,7 @@ case class HashAggregateExec( | |
|
|
||
| def outputFromRegularHashMap: String = { | ||
| s""" | ||
| |while ($limitNotReachedCond $iterTerm.next()) { | ||
| |while ($limitNotReachedCondition $iterTerm.next()) { | ||
| | UnsafeRow $keyTerm = (UnsafeRow) $iterTerm.getKey(); | ||
| | UnsafeRow $bufferTerm = (UnsafeRow) $iterTerm.getValue(); | ||
| | $outputFunc($keyTerm, $bufferTerm); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,10 +70,10 @@ class RowBasedHashMapGenerator( | |
| | | ||
| | | ||
| | public $generatedClassName( | ||
| | org.apache.spark.memory.TaskMemoryManager taskMemoryManager, | ||
| | org.apache.spark.TaskContext taskContext, | ||
| | InternalRow emptyAggregationBuffer) { | ||
| | batch = org.apache.spark.sql.catalyst.expressions.RowBasedKeyValueBatch | ||
| | .allocate($keySchema, $valueSchema, taskMemoryManager, capacity); | ||
| | .allocate($keySchema, $valueSchema, taskContext.taskMemoryManager(), capacity); | ||
| | | ||
| | final UnsafeProjection valueProjection = UnsafeProjection.create($valueSchema); | ||
| | final byte[] emptyBuffer = valueProjection.apply(emptyAggregationBuffer).getBytes(); | ||
|
|
@@ -87,6 +87,18 @@ class RowBasedHashMapGenerator( | |
| | | ||
| | buckets = new int[numBuckets]; | ||
| | java.util.Arrays.fill(buckets, -1); | ||
| | | ||
| | // Register a cleanup task with TaskContext to ensure that memory is guaranteed to be | ||
| | // freed at the end of the task. This is necessary to avoid memory leaks in when the | ||
| | // downstream operator does not fully consume the aggregation map's output | ||
| | // (e.g. aggregate followed by limit). | ||
| | taskContext.addTaskCompletionListener( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is needed as we need to clean up resource for query with limit and hash aggregate. Generally no query does limit early stop for partial aggregate so it's not a problem before this PR. However, we do have limit early stop for final aggregate, and it causes test failure of Use older style of Java syntax here instead of lambda expression, as janino does not support lambda expression compilation yet - https://github.com/janino-compiler/janino/blob/master/janino/src/main/java/org/codehaus/janino/UnitCompiler.java#L6998 .
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we do it outside of the fast hash map? Then we can apply it to both the row-based and vectorized fast hash map.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cloud-fan - sounds good, moved out to |
||
| | new org.apache.spark.util.TaskCompletionListener() { | ||
| | @Override | ||
| | public void onTaskCompletion(org.apache.spark.TaskContext context) { | ||
| | close(); | ||
| | } | ||
| | }); | ||
| | } | ||
| """.stripMargin | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change is needed as hash aggregation output order is changed, and it causes
ExpressionInfoSuite.check outputs of expression examplestest failure in https://github.com/c21/spark/runs/2386397792?check_suite_focus=true .