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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/EstimationUtils.scala:
##########
@@ -87,6 +88,15 @@ object EstimationUtils {
     expressions.collect {
       case alias @ Alias(attr: Attribute, _) if attributeStats.contains(attr) 
=>
         alias.toAttribute -> attributeStats(attr)
+      // Read-side CHAR padding wraps each CHAR column of a scan. With padding 
on, ANALYZE TABLE
+      // reads the table through it as well, so the statistics already 
describe padded values.
+      // Statistics collected otherwise (with padding off, or reported by a 
connector) can
+      // describe shorter values, and every non-null padded value has at least 
N characters.
+      case alias @ Alias(CharVarcharUtils.ReadSidePadding(attr: Attribute, 
Literal(n: Int, _)), _)
+          if attributeStats.contains(attr) =>
+        val stat = attributeStats(attr)
+        alias.toAttribute -> stat.copy(
+          avgLen = stat.avgLen.map(_.max(n)), maxLen = 
stat.maxLen.map(_.max(n)))

Review Comment:
   **9. [Low] The length floor also applies to all-null columns**
   
   The floor I suggested in comment 1 misses one case. For an all-null or empty 
column, `ANALYZE TABLE` stores `defaultSize` (20 for `StringType`) as `avgLen` 
and `maxLen`, from the `Coalesce(..., defaultSize)` in 
`CommandUtils.statExprs`. That 20 is a placeholder, not the length of any 
padded value, but the floor raises it to N. For an all-null `CHAR(100)` column, 
`getSizePerRow` now counts 112 bytes per row instead of 32.
   
   A distinct count of 0 marks this case, so the floor could skip it, for 
example:
   
   ```scala
   val stat = attributeStats(attr)
   val padded = if (stat.distinctCount.contains(BigInt(0))) stat else stat.copy(
     avgLen = stat.avgLen.map(_.max(n)), maxLen = stat.maxLen.map(_.max(n)))
   alias.toAttribute -> padded
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/FilterEstimation.scala:
##########
@@ -170,7 +173,11 @@ case class FilterEstimation(
    *         It returns None if the condition is not supported.
    */
   def calculateSingleCondition(condition: Expression, update: Boolean): 
Option[Double] = {
-    condition match {
+    val unpadded = condition.mapChildren {
+      case CharVarcharUtils.ReadSidePadding(attr: Attribute, _) => attr
+      case other => other
+    }
+    unpadded match {

Review Comment:
   **7. [Medium] An equality on a padded CHAR column can now reach `toDouble` 
with a string**
   
   Unwrapping the padding sends `c = 'x'` on a CHAR column to 
`evaluateEquality`. If the column stat has a histogram and min/max, it 
continues to `computeEqualityPossibilityByHistogram`, which calls 
`EstimationUtils.toDouble(literal.value, StringType)`. `toDouble` only handles 
numeric, date, timestamp and boolean types, so the optimizer fails with a 
`scala.MatchError`.
   
   `ANALYZE TABLE` never builds a histogram for strings, but 
`DataSourceV2Relation.transformV2Stats` copies min, max and the histogram from 
the connector for any type. A STRING column with such stats already fails the 
same way, so the bug itself is older. Before this PR, though, a CHAR column 
never got here, because the padded predicate was unsupported and estimated at 
1.0.
   
   Could `evaluateEquality` fall back to the distinct count for string and 
binary types, as the `valueInRange` match above does? If you'd rather keep this 
PR focused, a separate JIRA would work too, since STRING columns are affected 
as well.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/statsEstimation/FilterEstimationSuite.scala:
##########
@@ -963,6 +965,31 @@ class FilterEstimationSuite extends 
StatsEstimationTestBase {
       expectedRowCount = 10)
   }
 
+  test("SPARK-60810: predicates on a read-side padded CHAR column use the 
column's stats") {
+    val padded = CharVarcharUtils.addPaddingForScan(
+      
DataTypeUtils.toAttribute(CharVarcharUtils.replaceCharVarcharWithStringInSchema(
+        StructType(Seq(StructField("cstring", 
CharType(2))))).head).withExprId(attrString.exprId))
+    assert(padded match {
+      case CharVarcharUtils.ReadSidePadding(a: Attribute, _) => a.exprId == 
attrString.exprId
+      case _ => false
+    }, s"expected read-side padding over cstring, got $padded")
+    val child = childStatsTestPlan(Seq(attrString), tableRowCount = 10L)
+    // colStatString has 10 distinct values and no nulls over 10 rows.
+    Seq(
+      EqualTo(padded, Literal("A0")) -> 1,
+      EqualTo(Literal("A0"), padded) -> 1,
+      In(padded, Seq(Literal("A0"), Literal("A1"))) -> 2,
+      InSet(padded, Set(UTF8String.fromString("A0"), 
UTF8String.fromString("A1"))) -> 2,
+      IsNull(padded) -> 0).foreach { case (condition, expectedRows) =>
+      val filter = Filter(condition, child)
+      assert(filter.stats.rowCount.contains(BigInt(expectedRows)), condition)

Review Comment:
   **11. [Low] The cases check only the row count**
   
   Each case asserts `rowCount` only. The other half of the change is that, 
with `update`, the padded predicate's result is written to the stat of 
`cstring` itself, which the padding `Project` above then passes to its alias. 
If that update went to the wrong attribute or were skipped, every case here 
would still pass. Could the equality case also check the output column stat, as 
the `cstring = 'A2'` test does? For example:
   
   ```scala
   validateEstimatedStats(
     Filter(EqualTo(padded, Literal("A0")), child),
     Seq(attrString -> ColumnStat(distinctCount = Some(1), min = None, max = 
None,
       nullCount = Some(0), avgLen = Some(2), maxLen = Some(2))),
     expectedRowCount = 1)
   ```
   
   A `Not(EqualTo(padded, Literal("A0")))` case would also cover the path 
through `calculateFilterSelectivity` with `update = false`.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/FilterEstimation.scala:
##########
@@ -170,7 +173,11 @@ case class FilterEstimation(
    *         It returns None if the condition is not supported.
    */
   def calculateSingleCondition(condition: Expression, update: Boolean): 
Option[Double] = {
-    condition match {
+    val unpadded = condition.mapChildren {

Review Comment:
   **13. [Nit] `mapChildren` also rebuilds predicates that are not supported**
   
   `mapChildren` returns the same instance when no child changes, so predicates 
without padding are not copied. When a child is padded, though, the predicate 
is rebuilt before the match even if no case below supports it (for example 
`Like`, `StartsWith`, or a UDF), and the copy is then dropped. This is cheap, 
so feel free to ignore it. If you want to avoid it, the cases could match an 
extractor for "an attribute or its read-side padding" instead.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/CharVarcharUtils.scala:
##########
@@ -391,6 +391,22 @@ object CharVarcharUtils extends Logging with 
SparkCharVarcharUtils {
     }.getOrElse(attr)
   }
 
+  /**
+   * Matches the read-side CHAR padding that [[addPaddingForScan]] wraps a 
column in (when
+   * standard semantics are off) and extracts the padded expression and the 
length argument.
+   */
+  private[sql] object ReadSidePadding {
+    def unapply(e: Expression): Option[(Expression, Expression)] = e match {
+      case s: StaticInvoke
+          if s.scalarFunction.isEmpty &&
+            s.staticObject == classOf[CharVarcharCodegenUtils] &&
+            s.functionName == READ_SIDE_PADDING &&

Review Comment:
   **12. [Nit] For SPARK-60830, a dedicated expression instead of another name 
check?**
   
   One idea for SPARK-60830: `ReadSidePadding` recognizes the scan wrapper by 
the `StaticInvoke` function name, and as you noted, the checks need a matcher 
of their own with different length handling. Each wrapper then needs another 
name-based matcher, and every consumer has to know which one to use. A 
dedicated expression for the scan wrappers, carrying the length and whether 
values are padded or checked, might let these consumers match one type instead. 
Not for this PR.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/FilterEstimation.scala:
##########
@@ -161,7 +162,9 @@ case class FilterEstimation(
   /**
    * Returns a percentage of rows meeting a single condition in Filter node.
    * Currently we only support binary predicates where one side is a column,
-   * and the other is a literal.
+   * and the other is a literal. A side may also be the read-side CHAR padding 
of a column, which
+   * is what a predicate pushed below the padding Project compares. It is 
estimated as the column
+   * itself, as `EstimationUtils.getAliasStats` does above the padding Project.

Review Comment:
   **8. [Low] With CBO, the pruned relation's size now comes from this 
estimate**
   
   Following up on comment 3, where I called the partition pruning change an 
improvement: I missed how the result is used. `PruneFileSourcePartitions` saves 
the estimated `rowCount` next to `sizeInBytes = prunedFileIndex.sizeInBytes`. 
When CBO or plan stats are enabled, `CatalogStatistics.toPlanStats` replaces 
that size with `rowCount * rowSize`. For a CHAR partition column, the pruned 
relation's size now comes from the uniform `1 / ndv` estimate instead of the 
size of the remaining files.
   
   With skewed partitions, the error goes the unsafe way. Suppose a table has 
10 partition values and `p = '3'` holds 90% of the rows. The row count used to 
stay at the table's row count, an overestimate. It is now 10% of it, so 
`sizeInBytes` is about 9x too small, which can make the relation look small 
enough to broadcast.
   
   STRING partition columns already behave this way, so I don't think this 
blocks the PR. Could the description mention that, with CBO, this also changes 
the pruned relation's size?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statsEstimation/EstimationUtils.scala:
##########
@@ -87,6 +88,15 @@ object EstimationUtils {
     expressions.collect {
       case alias @ Alias(attr: Attribute, _) if attributeStats.contains(attr) 
=>
         alias.toAttribute -> attributeStats(attr)
+      // Read-side CHAR padding wraps each CHAR column of a scan. With padding 
on, ANALYZE TABLE
+      // reads the table through it as well, so the statistics already 
describe padded values.
+      // Statistics collected otherwise (with padding off, or reported by a 
connector) can
+      // describe shorter values, and every non-null padded value has at least 
N characters.

Review Comment:
   **10. [Nit] Could the comment mention the distinct count?**
   
   Following up on your reply to comment 1: I agree that the distinct count can 
only be too high when stored values differ just in trailing spaces. Since the 
pass-through adjusts the lengths but not the distinct count, could the comment 
say so? Before this PR such predicates were estimated at 1.0, so a too-high 
distinct count now gives an underestimate. For example:
   
   ```scala
         // Statistics collected otherwise (with padding off, or reported by a 
connector) can
         // describe shorter values, and every non-null padded value has at 
least N characters.
         // Their distinct count is left as is. It is too high only when stored 
values differ
         // just in trailing spaces, which the padding makes equal.
   ```



-- 
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