sunchao commented on code in PR #5615:
URL: https://github.com/apache/datafusion-comet/pull/5615#discussion_r3917090591
##########
spark/src/main/scala/org/apache/spark/sql/comet/operators.scala:
##########
@@ -336,7 +412,10 @@ private[comet] object NativeScanPlanDataInjector extends
PlanDataInjector {
op.getNativeScan.hasCommon &&
!op.getNativeScan.hasFilePartition
- override def getKey(op: Operator): Option[String] =
Some(sourceKey(op.getNativeScan.getCommon))
+ override def getKey(op: Operator): Option[String] = {
+ val common = op.getNativeScan.getCommon
+ Some(PlanDataInjector.cachedOrCompute(keyCache, common)(sourceKey(common)))
Review Comment:
Could we carry the existing driver-computed `sourceKey` in the serialized
`NativeScan` and read it directly here? [The driver already derives
it](https://github.com/apache/datafusion-comet/blob/843845940b4d8d3c4ab9efe9ec2a2851e2a33a04/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala#L365-L369).
Transporting that same key would preserve current matching semantics and the
injector interface while removing this LRU, repeated derivation after eviction,
and the dependency on sharing one protobuf instance to make lookup cheap.
It would also cover the native-shuffle path: [the writer builds its unified
plan from `spec.childNativeOp` and calls injection
directly](https://github.com/apache/datafusion-comet/blob/843845940b4d8d3c4ab9efe9ec2a2851e2a33a04/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala#L141-L155),
bypassing `parseBasePlan`. That child arrives through task dependency
deserialization, so a warm key-cache hit there still has to hash a fresh
protobuf and compare it structurally to the retained one. It avoids
stringification, but does not get the shared-instance fast path described above.
This is a proposed simplification, not a measured end-to-end alternative. It
needs the usual Java/Rust protobuf regeneration and a round-trip check
preserving key matching across query-context interning and scans with different
filters/projections. There is no need to change native injection or the contrib
SPI for this approach.
##########
spark/src/main/scala/org/apache/spark/sql/comet/operators.scala:
##########
@@ -95,6 +95,56 @@ private[comet] trait PlanDataInjector {
* Registry and utilities for injecting per-partition planning data into
operator trees.
*/
private[comet] object PlanDataInjector extends Logging {
+ import java.nio.ByteBuffer
+ import java.util.{LinkedHashMap, Map => JMap}
+
+ private[comet] final val maxCachedBasePlans = 16
+
+ // Every task of a stage deserializes its own byte-identical copy of the
base plan, so
+ // without a cache an executor re-parses the same operator tree once per
task. Parsed
+ // Operators are immutable, so one instance is safely shared across
concurrent tasks.
+ // Keyed by content (ByteBuffer hashes/compares the bytes) since the arrays
are distinct.
+ //
+ // Entries are whole parsed plan trees, so the entry count is what bounds
executor memory:
+ // at most 16 recent stages' plans stay live, LRU-evicted as stages turn
over. A stage
+ // rerun that misses after eviction simply re-parses.
+ private val basePlanCache = java.util.Collections.synchronizedMap(
+ new LinkedHashMap[ByteBuffer, Operator](4, 0.75f, true) {
+ override def removeEldestEntry(eldest: JMap.Entry[ByteBuffer,
Operator]): Boolean = {
+ size() > maxCachedBasePlans
+ }
+ })
+
+ /**
+ * Look up `key`, computing and inserting the value on a miss. The
computation runs outside any
+ * lock so unrelated misses never serialize behind each other; when two
threads race the same
+ * cold key, the first insert wins and the loser adopts it, keeping the
cached value
+ * reference-shared (which the sourceKey memo's identity fast path relies
on).
+ */
+ private[comet] def cachedOrCompute[K, V](cache: JMap[K, V], key: K)(compute:
=> V): V = {
+ val cached = cache.get(key)
+ if (cached != null) {
+ cached
+ } else {
+ val computed = compute
+ cache.synchronized {
+ val winner = cache.get(key)
+ if (winner != null) {
+ winner
+ } else {
+ cache.put(key, computed)
+ computed
+ }
+ }
+ }
+ }
+
+ /**
+ * Parse a stage's base plan bytes, sharing the parsed tree across the
executor's tasks. Falls
+ * back to a plain parse on eviction, so a stage rerun is always correct.
+ */
+ def parseBasePlan(bytes: Array[Byte]): Operator =
+ cachedOrCompute(basePlanCache,
ByteBuffer.wrap(bytes))(Operator.parseFrom(bytes))
Review Comment:
Could we give the content key a stored hash, calculated before entering the
cache monitor and preferably once on the driver? `ByteBuffer.hashCode()` scans
every byte on every lookup. `synchronizedMap.get()` computes that hash while
holding the executor-wide lock, so even warm hits serialize work proportional
to plan size. The same cost applies to the new common-data cache. A miss in an
already populated cache can hash the same bytes again for the second lookup and
insertion.
I measured a warmed, single-entry cache-hit operation using a 45,697-byte
protobuf plan with 1,000 Long columns (required/data schemas, fields and
projection), with each worker holding a distinct equal byte array. At 8
threads, repeated measurements gave:
| Key implementation | Aggregate elapsed microseconds per successful lookup |
| --- | ---: |
| Current ByteBuffer key | 52-53 |
| Hash computed outside the lock | 5.7 |
| Previously computed hash carried with the bytes | 1.6 |
The alternatives still perform full content equality on hits and collisions.
These are short component measurements on JDK 17 with a shared 16-CPU host, not
individual task latency or whole-query speedups. The precomputed-hash case
excludes hash preparation because the proposal performs it once before task
execution. This demonstrates avoidable lookup overhead, without claiming that
the PR is slower overall than its uncached base.
##########
spark/src/main/scala/org/apache/spark/sql/comet/operators.scala:
##########
@@ -328,6 +378,32 @@ private[comet] object IcebergPlanDataInjector extends
PlanDataInjector {
* Injector for NativeScan operators.
*/
private[comet] object NativeScanPlanDataInjector extends PlanDataInjector {
+ import java.nio.ByteBuffer
+ import java.util.{LinkedHashMap, Map => JMap}
+
+ private final val maxCacheEntries = 16
+
+ // Same rationale as IcebergPlanDataInjector's commonCache: the common bytes
are identical
+ // for every partition of a stage, and parsing them dominates inject() for
wide schemas.
+ private val commonCache = java.util.Collections.synchronizedMap(
+ new LinkedHashMap[ByteBuffer, OperatorOuterClass.NativeScanCommon](4,
0.75f, true) {
+ override def removeEldestEntry(
+ eldest: JMap.Entry[ByteBuffer,
OperatorOuterClass.NativeScanCommon]): Boolean = {
+ size() > maxCacheEntries
Review Comment:
Could the prepared scan data share the base plan's ownership/eviction unit?
The base cache holds 16 plans, but this cache and `keyCache` each hold only 16
scans. A single still-cached plan can therefore exceed both scan caches and
repeatedly evict everything needed by the next partition.
Using the exact cache/injector code in a component harness, traversing the
same distinct scans in the same order gave:
- One plan with 16 scans: the next pass reused 16/16 key strings and 16/16
parsed commons.
- One plan with 17 scans: the base plan was reused, but the next pass reused
0/17 keys and 0/17 commons.
- Nine plans with two distinct scans each: the next pass reused 9/9 base
plans, but 0/18 keys and 0/18 commons.
Thus schema-to-string key derivation and common parsing keep running even
while the relevant base plans are all cached. This is a conditional loss of the
intended reuse, not a demonstrated total regression versus the base. A prepared
entry owning the plan's keys and finalized common metadata would avoid
independent scan eviction. If preparation includes common data, its identity
must cover that finalized data or the execution, since resolved scalar-subquery
filters can differ for identical base-plan bytes. Please cover this scan-count
case in the performance validation.
--
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]