github-actions[bot] commented on code in PR #68480:
URL: https://github.com/apache/doris/pull/68480#discussion_r4091566728
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/model/Histogram.java:
##########
@@ -96,6 +113,18 @@ public static Histogram fromResultRow(ResultRow resultRow) {
}
histogramBuilder.setBuckets(buckets);
+ if (jsonObj.has("mcv_histogram") &&
jsonObj.get("mcv_histogram").isJsonObject()) {
+ JsonObject mcvObj = jsonObj.getAsJsonObject("mcv_histogram");
+ Map<Literal, Float> mcv =
StatisticsUtil.getHotValues(mcvObj.get("mcv").getAsString(), dataType);
Review Comment:
[P1] Decode every MCV entry that was persisted.
`StatisticsUtil.getHotValues` caps parsing with the loader thread's
current/default `hot_value_collect_count`, even though collection may have
stored a larger submitted count. Those discarded keys were excluded from the
residual histogram by the collection SQL, so they disappear from both
representations and their probability mass is reassigned incorrectly.
Deserialization must be driven by the payload, not an unrelated session/global
limit.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/HistogramTask.java:
##########
@@ -69,11 +105,12 @@ public void doExecute() throws Exception {
params.put("colName",
SqlUtils.getIdentSql(String.valueOf(info.colName)));
params.put("sampleRate", getSampleRateFunction());
params.put("maxBucketNum", String.valueOf(info.maxBucketNum));
+ params.put("mcvCount", String.valueOf(getHotValueCollectCount(info)));
- StringSubstitutor stringSubstitutor = new StringSubstitutor(params);
-
StatisticsUtil.execUpdate(stringSubstitutor.replace(ANALYZE_HISTOGRAM_SQL_TEMPLATE_TABLE));
+ StatisticsUtil.execUpdate(buildAnalyzeSql(params,
info.collectMcvHistogram));
Env.getCurrentEnv().getStatisticsCache().refreshHistogramSync(
Review Comment:
[P1] Make completion mean that the new histogram is actually published.
`refreshHistogramSync` ultimately starts an asynchronous Caffeine load and
discards its future; an immediate lookup can still be empty (the cache tests
compensate with a sleep). It also updates only this FE, unlike column-stat
publication, so followers can retain the old value until the 48-hour refresh.
Await the local load and broadcast/invalidate the histogram key on the other
FEs before a `WITH SYNC` analysis returns.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/BaseAnalysisTask.java:
##########
@@ -667,6 +668,13 @@ protected String castToNumeric(String colName) {
}
}
+ // The count the ANALYZE statement was submitted with; the default for a
job written before the
+ // count was kept, whose value is out of reach on this thread.
+ protected static int getHotValueCollectCount(AnalysisInfo info) {
+ return info.hotValueCollectCount > 0 ? info.hotValueCollectCount
Review Comment:
[P2] Preserve an explicit zero separately from legacy absence.
`hot_value_collect_count` accepts 0 and the job persists it, but this sentinel
replaces it with the worker thread's global default. A synchronous job can
therefore collect no MCVs while an asynchronous execution of the same submitted
setting collects the global count. Add presence/version information (or
disallow zero) rather than using `> 0` to distinguish old records.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/AnalysisManager.java:
##########
@@ -457,7 +461,10 @@ public void createTaskForEachColumns(AnalysisInfo jobInfo,
Map<Long, BaseAnalysi
TableIf table = jobInfo.getTable();
for (Pair<String, String> pair : jobColumns) {
AnalysisInfoBuilder colTaskInfoBuilder = new
AnalysisInfoBuilder(jobInfo);
- colTaskInfoBuilder.setAnalysisType(AnalysisType.FUNDAMENTALS);
+ // Per-column tasks are FUNDAMENTALS unless the job asked for a
histogram; INDEX is not a
+ // per-column analysis and is normalized away.
+ colTaskInfoBuilder.setAnalysisType(jobInfo.analysisType ==
AnalysisType.HISTOGRAM
Review Comment:
[P1] Reject or honor every histogram analysis scope before creating these
tasks. Four accepted routes do not produce the requested result: a rollup task
retains its index ID but both HistogramTask templates scan the unqualified
table without forcing that index, so the same scan can be stored under
different index keys; an explicit PARTITION request is retained in AnalysisInfo
but ignored by the SQL and by the storage key; `use.auto.analyzer=true` returns
a FUNDAMENTALS job; and external-table factories return fundamental tasks
although the command accepts HISTOGRAM. Either implement each route end to end
or reject it during validation so a successful ANALYZE cannot silently publish
the wrong scope (or no histogram at all).
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/cache/StatisticsCache.java:
##########
@@ -428,9 +429,18 @@ public ColumnStatistic getColumnStatistics(String colName,
ConnectContext ctx) {
if (shouldReturnUnknownStats(catalogId, schemaId, olapTable, ctx))
{
return ColumnStatistic.UNKNOWN;
}
- return doGetColumnStatistics(
+ ColumnStatistic columnStatistic = doGetColumnStatistics(
catalogId, schemaId, tableId, selectIndexId, colName, ctx
);
+ // the cached column stats object is shared, so attach the
histogram to a copy
+ if (ctx != null &&
ctx.getSessionVariable().isEnableHistogramJoinEstimation()
+ && !columnStatistic.isUnKnown) {
+ Histogram histogram = getHistogram(catalogId, schemaId,
tableId, selectIndexId, colName).orElse(null);
Review Comment:
[P1] Escape the histogram cache key before the loader embeds it in SQL. The
new loader substitutes raw `key.colName` into a quoted literal, whereas the
repository lookup escapes its constructed ID. Legal column names containing a
quote or backslash therefore make automatic histogram attachment fail even
though collection escaped the stored ID. Route this through the same escaping
helper as the repository path.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/HistogramTask.java:
##########
@@ -50,10 +51,45 @@ public class HistogramTask extends BaseAnalysisTask {
+ "FROM "
+ " ${dbName}.${tblName}";
+ // ANALYZE WITH HISTOGRAM + MCV: top-N hot values and residual histogram
in one scan.
+ private static final String ANALYZE_MCV_HISTOGRAM_SQL_TEMPLATE_TABLE =
"INSERT INTO "
+ + "${internalDB}.${histogramStatTbl} "
+ + "WITH src AS (SELECT ${colName} FROM ${dbName}.${tblName}), "
+ + "nn AS (SELECT COUNT(${colName}) AS c FROM src), "
+ + "hot AS (SELECT t.v, t.c FROM (SELECT ${colName} AS v, COUNT(*)
AS c FROM src "
+ + " WHERE ${colName} IS NOT NULL GROUP BY ${colName}) t, nn "
+ + " WHERE t.c / nn.c >= " +
StatisticsUtil.HOT_VALUE_MIN_RATIO_SQL
+ + " ORDER BY t.c DESC LIMIT ${mcvCount}), "
+ + "mcv AS (SELECT GROUP_CONCAT(CONCAT("
Review Comment:
[P1] Make the serialized MCV order deterministic. Ordering the `hot` CTE
chooses the top-N set but does not order rows consumed by `GROUP_CONCAT`; the
aggregate has no internal ORDER BY. The regression expects `hotValues[0]` to be
the hottest item, and nondeterminism also changes which values survive the
current parser cap. Add an explicit aggregate/input ordering with a stable
tie-breaker.
##########
fe/fe-core/src/main/java/org/apache/doris/catalog/InternalSchemaInitializer.java:
##########
@@ -341,6 +341,26 @@ public static void createTbl() throws UserException {
*/
createTable(getStatisticsCreateSql(StatisticConstants.PARTITION_STATISTIC_TBL_NAME,
Lists.newArrayList("catalog_id", "db_id", "tbl_id", "idx_id",
"part_name", "part_id", "col_id")));
+ /**
+ *CREATE TABLE IF NOT EXISTS
`internal`.`__internal_schema`.`histogram_statistics` (
+ * `id` varchar(4096) NOT NULL COMMENT "",
+ * `catalog_id` varchar(1024) NOT NULL COMMENT "",
+ * `db_id` varchar(1024) NOT NULL COMMENT "",
+ * `tbl_id` varchar(1024) NOT NULL COMMENT "",
+ * `idx_id` varchar(1024) NOT NULL COMMENT "",
+ * `col_id` varchar(1024) NOT NULL COMMENT "",
+ * `sample_rate` double NOT NULL COMMENT "",
+ * `buckets` varchar(65533) NOT NULL COMMENT "",
+ * `update_time` datetime NOT NULL COMMENT ""
+ * ) ENGINE = olap
+ * UNIQUE KEY(`id`, `catalog_id`, `db_id`, `tbl_id`, `idx_id`,
`col_id`)
+ * COMMENT "Doris internal statistics table, DO NOT MODIFY IT"
+ * DISTRIBUTED BY HASH(`id`, `catalog_id`, `db_id`, `tbl_id`,
`idx_id`, `col_id`)
+ * BUCKETS 7
+ * PROPERTIES ("replication_num" = "1")
+ */
+
createTable(getStatisticsCreateSql(StatisticConstants.HISTOGRAM_TBL_NAME,
Review Comment:
[P1] Add the new table to the complete statistics cleanup lifecycle. The
explicit table/database/index/column drop-stat paths and `StatisticsCleaner`
enumerate column/partition statistics only, so histogram rows survive
source-object deletion and accumulate indefinitely. They can also collide with
rerun assertions that query by column name. Every lifecycle operation that
removes existing statistics needs the matching histogram deletion.
##########
fe/fe-core/src/main/java/org/apache/doris/catalog/InternalSchemaInitializer.java:
##########
@@ -341,6 +341,26 @@ public static void createTbl() throws UserException {
*/
createTable(getStatisticsCreateSql(StatisticConstants.PARTITION_STATISTIC_TBL_NAME,
Lists.newArrayList("catalog_id", "db_id", "tbl_id", "idx_id",
"part_name", "part_id", "col_id")));
+ /**
+ *CREATE TABLE IF NOT EXISTS
`internal`.`__internal_schema`.`histogram_statistics` (
+ * `id` varchar(4096) NOT NULL COMMENT "",
+ * `catalog_id` varchar(1024) NOT NULL COMMENT "",
+ * `db_id` varchar(1024) NOT NULL COMMENT "",
+ * `tbl_id` varchar(1024) NOT NULL COMMENT "",
+ * `idx_id` varchar(1024) NOT NULL COMMENT "",
+ * `col_id` varchar(1024) NOT NULL COMMENT "",
+ * `sample_rate` double NOT NULL COMMENT "",
+ * `buckets` varchar(65533) NOT NULL COMMENT "",
+ * `update_time` datetime NOT NULL COMMENT ""
+ * ) ENGINE = olap
+ * UNIQUE KEY(`id`, `catalog_id`, `db_id`, `tbl_id`, `idx_id`,
`col_id`)
+ * COMMENT "Doris internal statistics table, DO NOT MODIFY IT"
+ * DISTRIBUTED BY HASH(`id`, `catalog_id`, `db_id`, `tbl_id`,
`idx_id`, `col_id`)
+ * BUCKETS 7
+ * PROPERTIES ("replication_num" = "1")
+ */
+
createTable(getStatisticsCreateSql(StatisticConstants.HISTOGRAM_TBL_NAME,
+ Lists.newArrayList("id", "catalog_id", "db_id", "tbl_id",
"idx_id", "col_id")));
Review Comment:
[P2] Apply the internal statistics replica policy to this table. It is
created with one replica, and the later `modifyTblReplicaCount` calls upgrade
the column, partition, and audit tables to three but omit
`histogram_statistics`. That leaves the newly default-consumed statistics less
durable than every peer internal table.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/HistogramTask.java:
##########
@@ -50,10 +51,45 @@ public class HistogramTask extends BaseAnalysisTask {
+ "FROM "
+ " ${dbName}.${tblName}";
+ // ANALYZE WITH HISTOGRAM + MCV: top-N hot values and residual histogram
in one scan.
+ private static final String ANALYZE_MCV_HISTOGRAM_SQL_TEMPLATE_TABLE =
"INSERT INTO "
+ + "${internalDB}.${histogramStatTbl} "
+ + "WITH src AS (SELECT ${colName} FROM ${dbName}.${tblName}), "
+ + "nn AS (SELECT COUNT(${colName}) AS c FROM src), "
+ + "hot AS (SELECT t.v, t.c FROM (SELECT ${colName} AS v, COUNT(*)
AS c FROM src "
+ + " WHERE ${colName} IS NOT NULL GROUP BY ${colName}) t, nn "
+ + " WHERE t.c / nn.c >= " +
StatisticsUtil.HOT_VALUE_MIN_RATIO_SQL
+ + " ORDER BY t.c DESC LIMIT ${mcvCount}), "
+ + "mcv AS (SELECT GROUP_CONCAT(CONCAT("
+ + " REPLACE(REPLACE(CAST(hot.v AS STRING), ':', '\\\\:'), ';',
'\\\\;'), "
+ + " ' :', ROUND(hot.c / nn.c, 4)), ' ;') AS s FROM hot, nn), "
Review Comment:
[P1] Preserve aggregate probability mass when serializing MCVs. Rounding
every entry to four decimals can create a large systematic error: 3,000 legal
values at ratio 0.00015 serialize as 0.0002, changing hot mass from 0.45 to
0.60, and JoinEstimation treats that rounded sum as exact when assigning
residual mass. Store adequate precision or compensate the residual from exact
aggregate counts.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/model/ColumnStatisticBuilder.java:
##########
@@ -59,6 +60,7 @@ public ColumnStatisticBuilder(ColumnStatistic
columnStatistic) {
this.original = columnStatistic.original;
this.updatedTime = columnStatistic.updatedTime;
this.hotValues = columnStatistic.hotValues;
+ this.histogram = columnStatistic.histogram;
Review Comment:
[P1] Do not implicitly preserve a distribution across value-changing
expressions. Many expression visitors copy a child with this constructor and
update only min/max, so `abs(-1)` still carries histogram/MCV key `-1` although
its output is `1`. A concrete consumer is:
```
Join(x = b.k)
├─ Project(abs(a.k) AS x)
│ └─ Scan A(a.k = -1, histogram at -1)
└─ Scan B(b.k = 1)
```
The default-on join estimator can conclude there is no overlap. Make
preservation opt-in for identity/order-preserving transforms, or
clear/transform both histogram and hot values in every value-changing visitor.
##########
fe/fe-core/src/main/java/org/apache/doris/catalog/InternalSchemaInitializer.java:
##########
@@ -566,6 +586,11 @@ private boolean created() {
return false;
}
+ optionalTable = db.getTable(StatisticConstants.HISTOGRAM_TBL_NAME);
Review Comment:
[P1] Do not publish statistics readiness before this required table exists.
`modifyColumnStatsTblSchema` sets `StatsTableSchemaValid=true` after checking
only the old column table, while this histogram-table check happens later in
the initializer loop; `statsTblAvailable()` also consults only the flag and
column table. During an upgrade, ANALYZE can pass its guard and target a table
that has not been created yet. Include all required tables in the readiness
condition and set the flag only after successful creation/validation.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/HistogramTask.java:
##########
@@ -50,10 +51,45 @@ public class HistogramTask extends BaseAnalysisTask {
+ "FROM "
+ " ${dbName}.${tblName}";
+ // ANALYZE WITH HISTOGRAM + MCV: top-N hot values and residual histogram
in one scan.
+ private static final String ANALYZE_MCV_HISTOGRAM_SQL_TEMPLATE_TABLE =
"INSERT INTO "
+ + "${internalDB}.${histogramStatTbl} "
+ + "WITH src AS (SELECT ${colName} FROM ${dbName}.${tblName}), "
+ + "nn AS (SELECT COUNT(${colName}) AS c FROM src), "
+ + "hot AS (SELECT t.v, t.c FROM (SELECT ${colName} AS v, COUNT(*)
AS c FROM src "
+ + " WHERE ${colName} IS NOT NULL GROUP BY ${colName}) t, nn "
+ + " WHERE t.c / nn.c >= " +
StatisticsUtil.HOT_VALUE_MIN_RATIO_SQL
+ + " ORDER BY t.c DESC LIMIT ${mcvCount}), "
+ + "mcv AS (SELECT GROUP_CONCAT(CONCAT("
+ + " REPLACE(REPLACE(CAST(hot.v AS STRING), ':', '\\\\:'), ';',
'\\\\;'), "
+ + " ' :', ROUND(hot.c / nn.c, 4)), ' ;') AS s FROM hot, nn), "
+ + "excl AS (SELECT HISTOGRAM(${colName}, ${maxBucketNum}) AS h
FROM src "
+ + " WHERE ${colName} NOT IN (SELECT v FROM hot)), "
+ + "fh AS (SELECT HISTOGRAM(${colName}, ${maxBucketNum}) AS h FROM
src) "
+ + "SELECT "
+ + " CONCAT(${tblId}, '-', ${idxId}, '-', '${colId}') AS id, "
+ + " ${catalogId} AS catalog_id, "
+ + " ${dbId} AS db_id, "
+ + " ${tblId} AS tbl_id, "
+ + " ${idxId} AS idx_id, "
+ + " '${colId}' AS col_id, "
+ + " ${sampleRate} AS sample_rate, "
+ + " JSON_INSERT(fh.h, '$.mcv_histogram', JSON_OBJECT("
Review Comment:
[P1] Keep the combined JSON within the `VARCHAR(65533)` storage contract.
This embeds hot string values in `mcv_histogram` while retaining them in the
full histogram, so previously storable endpoint JSON can overflow after the
default-on MCV section is added (for example, ten multi-KB singleton strings).
Add an encoded-byte budget/truncation policy that preserves probability
invariants, or use a storage type that can hold the documented payload.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/FilterEstimation.java:
##########
@@ -175,6 +179,7 @@ public Statistics visitOr(Or or, EstimationContext context)
{
colBuilder.setMinValue(union.getLow()).setMinExpr(union.getLowExpr())
.setMaxValue(union.getHigh()).setMaxExpr(union.getHighExpr())
.setNdv(union.getDistinctValues());
+ colBuilder.setHistogram(null);
Review Comment:
[P1] Repair all distribution representations for OR, including range
branches. Range visitors never register their slot in `keyColumns`, so `a < 10
OR a > 990` bypasses this block and retains the full 0..1000 histogram;
same-slot equality OR reaches the block but clears only the histogram while the
copied hot map can retain unrelated keys, and cross-slot OR leaves both
distributions untouched. For example:
```
Join(a.k = b.k)
├─ Filter(a.k < 10 OR a.k > 990)
│ └─ Scan A(a.k 0..1000)
└─ Filter(b.k = 500)
└─ Scan B
```
This publishes impossible middle-key mass. Track range keys and either union
compatible branch distributions or clear both histogram and hot values for
every affected slot.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/FilterEstimation.java:
##########
@@ -257,6 +262,11 @@ private Statistics estimateColumnLessThanConstant(LessThan
cp, DataType dataType
}
}
}
+ if (leftStats.histogram != null) {
Review Comment:
[P1] Clear or convert the histogram when temporarily converting VARCHAR
statistics to DATETIME. `tryConvertStringColStatsToDateColStats` copies the
VARCHAR histogram while changing min/max and hot values, then this code
intersects packed-string bucket doubles with datetime-unit bounds. For example:
```
Filter(s < '2025-06-01')
└─ Scan T(s VARCHAR ISO-date histogram)
```
A matching range can appear disjoint. `ExpressionEstimation.castMinMax`
already clears histograms for this domain change; apply the same invariant here
(or transform every bucket exactly).
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/FilterEstimation.java:
##########
@@ -893,6 +952,7 @@ public Statistics visitNot(Not not, EstimationContext
context) {
// 4. not A like XXX
// 5. not array_contains([xx, xx], xx)
colBuilder.setNumNulls(0);
+ colBuilder.setHistogram(originColStats.histogram);
Review Comment:
[P1] Keep distributions invalidated for `NOT LIKE`. LIKE correctly cannot
derive a histogram/hot map, but this complement path restores the original
histogram and, when the child map is null, all original hot values. For example:
```
Join(a.k = b.k)
├─ Filter(NOT(a.k LIKE 'a%'))
│ └─ Scan A(a.k has hot key 'apple')
└─ Scan B(b.k = 'apple')
```
The upper join sees a key the filter removed. Only restore/derive a
distribution for complements whose excluded support is represented exactly.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/FilterEstimation.java:
##########
@@ -1023,6 +1093,7 @@ public boolean isKeySlot(Expression expr) {
private Statistics estimateColumnEqualToColumn(Expression leftExpr,
ColumnStatistic leftStats,
Expression rightExpr, ColumnStatistic rightStats, boolean
keepNull, EstimationContext context) {
ColumnStatisticBuilder intersectBuilder = new
ColumnStatisticBuilder(leftStats);
+ intersectBuilder.setHistogram(null);
Review Comment:
[P1] Recompute or clear conditional hot ratios for column equality. This
clears the histogram, but the branch retains `min(leftRatio,rightRatio)` as if
it were already normalized to the filtered output. With asymmetric marginals, a
realizable result split 50/50 over keys 0 and 1 can be published as
`{0:.1,1:.1}`; `Histogram.fromHotValues` then invents 0.8 residual mass despite
NDV 2. A downstream consumer is:
```
Join(a.k = c.k)
├─ Filter(a.k = b.k)
│ └─ Scan T(asymmetric a/b marginals)
└─ Scan C(c.k = 0)
```
Normalize by the equality selectivity/row count, or invalidate the map.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/FilterEstimation.java:
##########
@@ -841,6 +889,17 @@ public Statistics visitInPredicate(InPredicate
inPredicate, EstimationContext co
selectivity =
newCompareExprStats.getHotValues().values().stream().mapToDouble(x -> x).sum();
}
}
+ if (compareExprStats.histogram != null &&
options.stream().allMatch(Literal.class::isInstance)) {
Review Comment:
[P1] Remove NULL and deduplicate literals before histogram IN arithmetic.
`NullLiteral#getDouble()` contributes numeric zero, `getValuesSelectivity` sums
every option, and the existing deduplication is intentionally skipped for lists
above 200. Thus `IN (NULL, 5)` can acquire key-zero mass and 201 copies of `5`
can saturate selectivity. A downstream case is:
```
Join(a.k = b.k)
├─ Filter(a.k IN (NULL, 5, 5, ...))
│ └─ Scan A
└─ Scan B
```
Normalize SQL-IN semantics before calling the histogram.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/FilterEstimation.java:
##########
@@ -1113,6 +1184,7 @@ private Statistics
estimateColumnLessThanColumn(Expression leftExpr, ColumnStati
.setMinValue(leftRange.getLow())
.setNdv(leftStats.ndv * (leftAlwaysLessThanRightPercent +
leftOverlapPercent))
.setNumNulls(0)
+ .setHistogram(null)
Review Comment:
[P1] Invalidate hot values together with the histogram for column
inequalities. These branches copy the input hot map while narrowing
min/max/NDV, so excluded keys remain visible. For example:
```
Join(a.k = c.k)
├─ Filter(a.k < b.k)
│ └─ Scan T(a.k hot at 100, b.k = 50)
└─ Scan C(c.k = 100)
```
The upper join estimates an impossible match. Filter the hot map against the
derived condition when exact, otherwise clear it.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/JoinEstimation.java:
##########
@@ -595,65 +783,21 @@ private static void
updateJoinConditionColumnStatistics(Statistics inputStats, J
}
if (joinType.isInnerJoin() || joinType.isAsofInnerJoin()) {
ColumnStatisticBuilder builder = new
ColumnStatisticBuilder(leftColStats);
- builder.setNdv(Math.min(leftColStats.ndv, rightColStats.ndv));
- // update hot values
- if (leftColStats.getHotValues() != null &&
rightColStats.getHotValues() != null) {
- Map<Literal, Float> newHotValues = Maps.newHashMap();
- for (Literal literal :
leftColStats.getHotValues().keySet()) {
- if (rightColStats.getHotValues().containsKey(literal))
{
- newHotValues.put(literal,
Math.min(leftColStats.getHotValues().get(literal),
-
rightColStats.getHotValues().get(literal)));
- }
- }
- if (newHotValues.isEmpty()) {
- builder.setHotValues(null);
- } else {
- builder.setHotValues(newHotValues);
- }
- }
- updatedCols.put(eqLeft, builder.build());
- updatedCols.put(eqRight, builder.build());
+ mergeJoinKeyStatistics(leftColStats, rightColStats, builder);
+ ColumnStatistic merged = builder.build();
Review Comment:
[P1] Derive ASOF-inner distributions while both input statistics are still
available. The ASOF estimator returns only probe-side column stats; this later
update looks up the absent build expression as UNKNOWN and the one-sided
fallback republishes the probe histogram even when build support reduced the
row count. For example:
```
Join(j.k = C.k)
├─ ASOF_LEFT_INNER(A.k = B.k)
│ ├─ Scan A(k = 0 or 1)
│ └─ Filter(B.k = 0)
└─ Scan C(k = 1)
```
The upper join sees key 1 although the ASOF build excluded it. Condition the
distribution before discarding the build map, or pass both original inputs into
this update.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/JoinEstimation.java:
##########
@@ -54,16 +63,182 @@ public class JoinEstimation {
private static double TRUSTABLE_UNIQ_THRESHOLD = 0.9;
private static double OUTER_JOIN_NULL_SUPPLELMENT_RATIO = 0.1;
+ private static final double MIN_JOIN_KEY_SELECTIVITY = 1e-12;
+
private static boolean shouldDecayRemainingUntrustConditions() {
ConnectContext connectContext = ConnectContext.get();
return connectContext == null || connectContext.getSessionVariable()
== null
||
connectContext.getSessionVariable().isEnableLowConfidenceEqJoinRemainingConditionDecay();
}
+ private static boolean isMcvJoinEstimationEnabled() {
+ ConnectContext ctx = ConnectContext.get();
+ return ctx != null && ctx.getSessionVariable() != null &&
ctx.getSessionVariable().isEnableMcvJoinEstimation();
+ }
+
+ private static boolean isHistogramJoinEstimationEnabled() {
+ ConnectContext ctx = ConnectContext.get();
+ return ctx != null && ctx.getSessionVariable() != null
+ && ctx.getSessionVariable().isEnableHistogramJoinEstimation();
+ }
+
private static void normalizeColumnStatistics(Statistics outputStats,
Statistics inputStats) {
outputStats.normalizeColumnStatistics(inputStats.getRowCount(), false);
}
+ /**
+ * Equi-join key selectivity as sum_v p_L(v) * p_R(v) over hot values and
histogram buckets.
+ * A side without a histogram is treated as one unbounded bucket. When
{@code joinedKeyStats}
+ * is non-null, also fill the join-key column stats for the output.
+ */
+ private static double estimateJoinKeySelectivity(ColumnStatistic
leftColStats, ColumnStatistic rightColStats,
+ ColumnStatisticBuilder joinedKeyStats) {
+ Histogram leftHistogram = getJoinHistogram(leftColStats);
+ Histogram rightHistogram = getJoinHistogram(rightColStats);
+ double leftNdv = Math.max(1, leftColStats.ndv);
+ double rightNdv = Math.max(1, rightColStats.ndv);
+ // Missing histogram: one unbounded bucket; selectivity falls back to
1/max(ndv).
+ Map<Literal, Float> leftHotValues = leftHistogram == null ?
Collections.emptyMap() : leftHistogram.mcv;
+ Map<Literal, Float> rightHotValues = rightHistogram == null ?
Collections.emptyMap() : rightHistogram.mcv;
+ List<Bucket> leftBuckets = leftHistogram == null ?
Collections.singletonList(
+ new Bucket(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY,
1, 0, leftNdv))
+ : leftHistogram.hasMcv() ? leftHistogram.mcvBuckets :
leftHistogram.buckets;
+ List<Bucket> rightBuckets = rightHistogram == null ?
Collections.singletonList(
+ new Bucket(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY,
1, 0, rightNdv))
+ : rightHistogram.hasMcv() ? rightHistogram.mcvBuckets :
rightHistogram.buckets;
+ double leftCountToRatio = Math.max(0, 1 -
leftHotValues.values().stream().mapToDouble(r -> r).sum())
+ /
StatsMathUtil.nonZeroDivisor(leftBuckets.stream().mapToDouble(b ->
b.count).sum());
+ double rightCountToRatio = Math.max(0, 1 -
rightHotValues.values().stream().mapToDouble(r -> r).sum())
+ /
StatsMathUtil.nonZeroDivisor(rightBuckets.stream().mapToDouble(b ->
b.count).sum());
+
+ Map<Literal, Double> outputHotValues = new LinkedHashMap<>();
+ double selectivity = 0;
+
+ for (Map.Entry<Literal, Float> entry : leftHotValues.entrySet()) {
+ Literal rightKey = StatisticsUtil.findHotValueKey(rightHotValues,
entry.getKey());
+ double rightRatio = rightKey != null ? rightHotValues.get(rightKey)
+ : rightHistogram != null ?
getBucketValueRatio(rightBuckets, entry.getKey(), rightCountToRatio)
+ : getNdvValueRatio(rightColStats, entry.getKey(),
Math.max(leftNdv, rightNdv));
+ if (rightRatio > 0) {
+ double ratio = entry.getValue() * rightRatio;
+ outputHotValues.put(entry.getKey(), ratio);
+ selectivity += ratio;
+ }
+ }
+ for (Map.Entry<Literal, Float> entry : rightHotValues.entrySet()) {
+ if (StatisticsUtil.findHotValueKey(leftHotValues, entry.getKey())
!= null) {
+ continue;
+ }
+ double leftRatio = leftHistogram != null
+ ? getBucketValueRatio(leftBuckets, entry.getKey(),
leftCountToRatio)
+ : getNdvValueRatio(leftColStats, entry.getKey(),
Math.max(leftNdv, rightNdv));
+ if (leftRatio > 0) {
+ double ratio = entry.getValue() * leftRatio;
+ outputHotValues.put(entry.getKey(), ratio);
+ selectivity += ratio;
+ }
+ }
+ // Bucket-bucket overlap (sorted merge).
+ List<Bucket> outputBuckets = Lists.newArrayList();
+ DataType leftType = leftHistogram == null ? NullType.INSTANCE :
leftHistogram.getDataType();
+ DataType rightType = rightHistogram == null ? NullType.INSTANCE :
rightHistogram.getDataType();
+ int leftIndex = 0;
+ int rightIndex = 0;
+ while (leftIndex < leftBuckets.size() && rightIndex <
rightBuckets.size()) {
Review Comment:
[P1] Exclude the union of both sides' hot keys from residual bucket overlap.
With left `{0:.5,1:.25,2:.25}`/hot 0 and right `{0:.25,1:.5,2:.25}`/hot 1,
hot-to-residual terms add .25, then the 0..2 residual buckets add .125 even
though their only common residual key is 2. The result is .375 instead of .3125:
```
Join(A.k = B.k)
├─ Scan A(hot 0; residual 1,2)
└─ Scan B(hot 1; residual 0,2)
```
Build residual buckets after removing both hot-key sets, or subtract those
cross-hot contributions from the overlap.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/JoinEstimation.java:
##########
@@ -54,16 +63,182 @@ public class JoinEstimation {
private static double TRUSTABLE_UNIQ_THRESHOLD = 0.9;
private static double OUTER_JOIN_NULL_SUPPLELMENT_RATIO = 0.1;
+ private static final double MIN_JOIN_KEY_SELECTIVITY = 1e-12;
+
private static boolean shouldDecayRemainingUntrustConditions() {
ConnectContext connectContext = ConnectContext.get();
return connectContext == null || connectContext.getSessionVariable()
== null
||
connectContext.getSessionVariable().isEnableLowConfidenceEqJoinRemainingConditionDecay();
}
+ private static boolean isMcvJoinEstimationEnabled() {
+ ConnectContext ctx = ConnectContext.get();
+ return ctx != null && ctx.getSessionVariable() != null &&
ctx.getSessionVariable().isEnableMcvJoinEstimation();
+ }
+
+ private static boolean isHistogramJoinEstimationEnabled() {
+ ConnectContext ctx = ConnectContext.get();
+ return ctx != null && ctx.getSessionVariable() != null
+ && ctx.getSessionVariable().isEnableHistogramJoinEstimation();
+ }
+
private static void normalizeColumnStatistics(Statistics outputStats,
Statistics inputStats) {
outputStats.normalizeColumnStatistics(inputStats.getRowCount(), false);
}
+ /**
+ * Equi-join key selectivity as sum_v p_L(v) * p_R(v) over hot values and
histogram buckets.
+ * A side without a histogram is treated as one unbounded bucket. When
{@code joinedKeyStats}
+ * is non-null, also fill the join-key column stats for the output.
+ */
+ private static double estimateJoinKeySelectivity(ColumnStatistic
leftColStats, ColumnStatistic rightColStats,
+ ColumnStatisticBuilder joinedKeyStats) {
+ Histogram leftHistogram = getJoinHistogram(leftColStats);
+ Histogram rightHistogram = getJoinHistogram(rightColStats);
+ double leftNdv = Math.max(1, leftColStats.ndv);
+ double rightNdv = Math.max(1, rightColStats.ndv);
+ // Missing histogram: one unbounded bucket; selectivity falls back to
1/max(ndv).
+ Map<Literal, Float> leftHotValues = leftHistogram == null ?
Collections.emptyMap() : leftHistogram.mcv;
+ Map<Literal, Float> rightHotValues = rightHistogram == null ?
Collections.emptyMap() : rightHistogram.mcv;
+ List<Bucket> leftBuckets = leftHistogram == null ?
Collections.singletonList(
Review Comment:
[P1] Bound a synthesized side by its known min/max instead of making it
infinite. If A has statistics 0..9 but no histogram and B has a histogram at
100..109, this bucket creates overlap and then assigns B's 100..109 output
shape to both keys:
```
Join(j.k = c.k)
├─ Join(A.k = B.k)
│ ├─ Scan A(k 0..9, no histogram)
│ └─ Scan B(k histogram 100..109)
└─ Scan C
```
The first join is empty, yet the chained plan receives a populated
distribution. Use each column's finite bounds (and type) in the fallback bucket.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/JoinEstimation.java:
##########
@@ -595,65 +783,21 @@ private static void
updateJoinConditionColumnStatistics(Statistics inputStats, J
}
if (joinType.isInnerJoin() || joinType.isAsofInnerJoin()) {
ColumnStatisticBuilder builder = new
ColumnStatisticBuilder(leftColStats);
- builder.setNdv(Math.min(leftColStats.ndv, rightColStats.ndv));
- // update hot values
- if (leftColStats.getHotValues() != null &&
rightColStats.getHotValues() != null) {
- Map<Literal, Float> newHotValues = Maps.newHashMap();
- for (Literal literal :
leftColStats.getHotValues().keySet()) {
- if (rightColStats.getHotValues().containsKey(literal))
{
- newHotValues.put(literal,
Math.min(leftColStats.getHotValues().get(literal),
-
rightColStats.getHotValues().get(literal)));
- }
- }
- if (newHotValues.isEmpty()) {
- builder.setHotValues(null);
- } else {
- builder.setHotValues(newHotValues);
- }
- }
- updatedCols.put(eqLeft, builder.build());
- updatedCols.put(eqRight, builder.build());
+ mergeJoinKeyStatistics(leftColStats, rightColStats, builder);
+ ColumnStatistic merged = builder.build();
+ updatedCols.put(eqLeft, merged);
+ updatedCols.put(eqRight, merged);
Review Comment:
[P1] Do not write a cast-domain distribution under the raw child slot. For
`CAST(A.s AS DATE)=B.d`, expression estimation clears A's VARCHAR histogram,
then the one-sided merge can build a DATE histogram from B; stripping the Cast
here assigns those DATE buckets to raw `A.s`. A later raw join compares
incompatible encodings:
```
Join(j.s = C.s)
├─ Join(CAST(A.s AS DATE) = B.d)
└─ Scan C(s VARCHAR)
```
Only map merged distributions back when the expression and slot share the
same value domain, otherwise keep them on the expression or clear them.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java:
##########
@@ -1240,7 +1241,21 @@ private ColumnStatistic getColumnStatistic(
builder.merge(pcolStats);
}
}
- return builder.toColumnStatistics();
+ ColumnStatistic columnStatistic = builder.toColumnStatistics();
+ if (connectContext != null
+ &&
connectContext.getSessionVariable().isEnableHistogramJoinEstimation()) {
+ Histogram histogram =
Env.getCurrentEnv().getStatisticsCache().getHistogram(
+ olapTableStatistics.catalogId,
olapTableStatistics.schemaId,
+ olapTableStatistics.tableId,
olapTableStatistics.selectIndexId, colName).orElse(null);
+ if (histogram != null && !histogram.hasCollapsedBuckets())
{
+ // the histogram is table level, the merged column
stats cover the
+ // selected partitions only
+ columnStatistic = new
ColumnStatisticBuilder(columnStatistic).setHistogram(histogram
Review Comment:
[P1] Do not attach a table-level histogram to a proper partition subset.
Min/max clipping cannot remove holes from unselected partitions or repair
partition correlation for non-partition columns, and the fallback path is
broader still. For example:
```
Join(A.k = B.k)
├─ Scan A PARTITION(p1) // table histogram covers p1+p2
└─ Scan B
```
A value present only in p2 remains inside the clipped range and creates a
false join. Collect/key histograms per partition and merge selected partitions,
or omit the histogram whenever scan statistics are partition-scoped.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/JoinEstimation.java:
##########
@@ -595,65 +783,21 @@ private static void
updateJoinConditionColumnStatistics(Statistics inputStats, J
}
if (joinType.isInnerJoin() || joinType.isAsofInnerJoin()) {
ColumnStatisticBuilder builder = new
ColumnStatisticBuilder(leftColStats);
- builder.setNdv(Math.min(leftColStats.ndv, rightColStats.ndv));
- // update hot values
- if (leftColStats.getHotValues() != null &&
rightColStats.getHotValues() != null) {
- Map<Literal, Float> newHotValues = Maps.newHashMap();
- for (Literal literal :
leftColStats.getHotValues().keySet()) {
- if (rightColStats.getHotValues().containsKey(literal))
{
- newHotValues.put(literal,
Math.min(leftColStats.getHotValues().get(literal),
-
rightColStats.getHotValues().get(literal)));
- }
- }
- if (newHotValues.isEmpty()) {
- builder.setHotValues(null);
- } else {
- builder.setHotValues(newHotValues);
- }
- }
- updatedCols.put(eqLeft, builder.build());
- updatedCols.put(eqRight, builder.build());
+ mergeJoinKeyStatistics(leftColStats, rightColStats, builder);
+ ColumnStatistic merged = builder.build();
+ updatedCols.put(eqLeft, merged);
+ updatedCols.put(eqRight, merged);
} else if (joinType.isLeftOuterJoin() ||
joinType.isAsofLeftOuterJoin()) {
ColumnStatisticBuilder rightBuilder = new
ColumnStatisticBuilder(rightColStats);
- rightBuilder.setNdv(Math.min(leftColStats.ndv,
rightColStats.ndv));
- // update hot values
- if (leftColStats.getHotValues() != null &&
rightColStats.getHotValues() != null) {
- Map<Literal, Float> newHotValues = Maps.newHashMap();
- for (Literal literal :
leftColStats.getHotValues().keySet()) {
- if (rightColStats.getHotValues().containsKey(literal))
{
- newHotValues.put(literal,
Math.min(leftColStats.getHotValues().get(literal),
-
rightColStats.getHotValues().get(literal)));
- }
- }
- if (newHotValues.isEmpty()) {
- rightBuilder.setHotValues(null);
- } else {
- rightBuilder.setHotValues(newHotValues);
- }
- }
+ mergeJoinKeyStatistics(leftColStats, rightColStats,
rightBuilder);
Review Comment:
[P1] Clear or derive preserved-key distributions for every non-inner join.
Semi/anti branches change only NDV, so they can retain keys removed by
existence filtering; outer/ASOF-outer branches leave preserved-side ratios
unchanged even though build multiplicity can reweight them. These stale shapes
feed later joins:
```
Join(j.k = C.k)
├─ LeftSemi/LeftAnti/LeftOuter(A.k = B.k)
└─ Scan C
```
A semi join may remove a key entirely, while an outer join can turn a 1:1
key ratio into 100:1. Establish the same post-join distribution invariant for
semi, anti, left/right/full outer, and ASOF-outer branches.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/JoinEstimation.java:
##########
@@ -595,65 +783,21 @@ private static void
updateJoinConditionColumnStatistics(Statistics inputStats, J
}
if (joinType.isInnerJoin() || joinType.isAsofInnerJoin()) {
ColumnStatisticBuilder builder = new
ColumnStatisticBuilder(leftColStats);
- builder.setNdv(Math.min(leftColStats.ndv, rightColStats.ndv));
- // update hot values
- if (leftColStats.getHotValues() != null &&
rightColStats.getHotValues() != null) {
- Map<Literal, Float> newHotValues = Maps.newHashMap();
- for (Literal literal :
leftColStats.getHotValues().keySet()) {
- if (rightColStats.getHotValues().containsKey(literal))
{
- newHotValues.put(literal,
Math.min(leftColStats.getHotValues().get(literal),
-
rightColStats.getHotValues().get(literal)));
- }
- }
- if (newHotValues.isEmpty()) {
- builder.setHotValues(null);
- } else {
- builder.setHotValues(newHotValues);
- }
- }
- updatedCols.put(eqLeft, builder.build());
- updatedCols.put(eqRight, builder.build());
+ mergeJoinKeyStatistics(leftColStats, rightColStats, builder);
+ ColumnStatistic merged = builder.build();
+ updatedCols.put(eqLeft, merged);
Review Comment:
[P1] Intersect distributions for equality conjuncts that share a key instead
of letting the last map write win. Each conjunct is estimated from unchanged
input stats and deferred into `updatedCols`, so predicate order determines the
final support:
```
Join(j.k = C.k)
├─ Join(A.k = B.x AND A.k = B.y)
└─ Scan C(k = 2)
```
If the first equality restricts A.k to `{1}` and the second to `{2}`, the
join is empty but the last assignment can publish `{2}`. Apply each update to
the accumulated statistics or intersect per-expression candidates before
committing them.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/model/Histogram.java:
##########
@@ -172,12 +201,213 @@ public static JsonArray getBucketsJson(List<Bucket>
buckets) {
return bucketsJsonArray;
}
- public double size() {
- if (CollectionUtils.isEmpty(buckets)) {
+ /** Build a histogram from column hot values when stored histogram has no
MCV section. */
+ public static Histogram fromHotValues(ColumnStatistic colStats) {
+ Map<Literal, Float> hotValues =
StatisticsUtil.getHotValuesWithOriginalThreshold(colStats.hotValues,
+ Math.max(1, colStats.ndv));
+ if (hotValues == null) {
+ return null;
+ }
+ if (colStats.histogram != null && !colStats.histogram.hasMcv()) {
+ Histogram withoutHotValues =
colStats.histogram.removeValues(hotValues.keySet());
+ return new Histogram(colStats.histogram.dataType, 0, 0,
Collections.emptyList(), hotValues,
+ withoutHotValues == null ? Collections.emptyList() :
withoutHotValues.buckets);
+ }
+ double hotRatio = hotValues.values().stream().mapToDouble(r ->
r).sum();
+ List<Bucket> buckets = hotRatio >= 1 ? Collections.emptyList() :
Lists.newArrayList(new Bucket(
+ colStats.minValue, colStats.maxValue, 1 - hotRatio, 0,
Math.max(1, colStats.ndv - hotValues.size())));
+ Type dataType = colStats.minExpr != null ? colStats.minExpr.getType()
: Type.NULL;
+ return new Histogram(dataType, 0, 0, Collections.emptyList(),
hotValues, buckets);
+ }
+
+ public boolean hasMcv() {
+ return !mcv.isEmpty();
+ }
+
+ /** True if a multi-ndv bucket collapses to equal bounds (e.g. ints beyond
2^53). */
+ public boolean hasCollapsedBuckets() {
+ for (Bucket bucket : Iterables.concat(buckets, mcvBuckets)) {
+ if (bucket.ndv > 1 && bucket.upper <= bucket.lower) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Restrict histogram to [lower, upper]; open interval when {@code
inclusive} is false. */
+ public Histogram intersectRange(double lower, double upper, boolean
inclusive) {
+ DataType type = getDataType();
+ if (!inclusive) {
+ lower = nextValueAbove(lower, type);
+ upper = nextValueBelow(upper, type);
+ }
+ return rebuild(intersectBuckets(buckets, lower, upper, type),
mcvInRange(lower, upper),
+ intersectBuckets(mcvBuckets, lower, upper, type));
+ }
+
+ private static double nextValueAbove(double value, DataType type) {
Review Comment:
[P1] Carry exact typed strict endpoints instead of advancing encoded
doubles. `Math.nextUp(yyyymmdd)` converts back to the same DATE, and integers
above 2^53 lose adjacency (`2^53 + 1 == 2^53` as double). Thus:
```
Join(f.k = B.k)
├─ Filter(A.k > 9007199254740992)
└─ Scan B(k = 9007199254740992)
```
can retain the excluded boundary; DATE strict predicates have the same
problem. Represent inclusivity separately or use type-aware literal
successor/predecessor operations, including collision checks for singleton
buckets.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java:
##########
@@ -1618,10 +1654,13 @@ public Statistics computeUnion(Union union,
List<Statistics> childStats) {
unionHotValues.entrySet().stream()
.sorted((a, b) -> Float.compare(b.getValue(),
a.getValue()))
.limit(maxHotValueCount)
- .forEach(e -> resultHotValues.put(e.getKey(), (float)
(e.getValue() / unionRowCount)));
+ .forEach(e -> resultHotValues.put(e.getKey(),
+ (float) (e.getValue() / unionNotNullRowCount)));
if (!resultHotValues.isEmpty()) {
colStatsBuilder.setHotValues(resultHotValues);
}
+ // the histograms of the children are not merged
+ colStatsBuilder.setHistogram(null);
Review Comment:
[P1] Apply this distribution invalidation to the other row-changing
operators. UNION clears child histograms here, but EXCEPT, INTERSECT, and
GENERATE copy histogram/hot values even though EXCEPT can remove an entire hot
key and GENERATE can reweight keys by correlated expansion. For example:
```
Join(x.k = B.k)
├─ Except(Scan A, Scan R) // or Generate(A.k, correlated array)
└─ Scan B
```
The upper join consumes support/ratios that no longer describe the output.
Derive them exactly or clear both histogram and hot values in every parallel
operator.
##########
fe/fe-core/src/test/java/org/apache/doris/nereids/stats/StatsCalculatorTest.java:
##########
@@ -366,11 +368,13 @@ public void testHashJoinSkew() {
ColumnStatistic icStatsOut = outputStats.findColumnStatistics(ic);
Assertions.assertEquals(5, icStatsOut.getHotValues().size());
+ // the join key keeps every value skewed on one side that the other
side holds too, so
+ // "1" of ia and "3", "4" of ib join "2" rather than only the values
hot on both sides
ColumnStatistic iaStatsOut = outputStats.findColumnStatistics(ia);
- Assertions.assertEquals(1, iaStatsOut.getHotValues().size());
+ Assertions.assertEquals(4, iaStatsOut.getHotValues().size());
Review Comment:
[P1] Install and restore an enabled `ConnectContext` for this assertion.
`isMcvJoinEstimationEnabled()` requires a thread-local context, but this test
class does not create one here, so production code takes the legacy
intersection path; that path yields one common hot key, not the four asserted
on this line. Without setting the session switch, the changed unit test should
fail rather than exercise the new MCV merge.
##########
fe/fe-core/src/main/java/org/apache/doris/statistics/model/Histogram.java:
##########
@@ -172,12 +201,213 @@ public static JsonArray getBucketsJson(List<Bucket>
buckets) {
return bucketsJsonArray;
}
- public double size() {
- if (CollectionUtils.isEmpty(buckets)) {
+ /** Build a histogram from column hot values when stored histogram has no
MCV section. */
+ public static Histogram fromHotValues(ColumnStatistic colStats) {
+ Map<Literal, Float> hotValues =
StatisticsUtil.getHotValuesWithOriginalThreshold(colStats.hotValues,
+ Math.max(1, colStats.ndv));
+ if (hotValues == null) {
+ return null;
+ }
+ if (colStats.histogram != null && !colStats.histogram.hasMcv()) {
+ Histogram withoutHotValues =
colStats.histogram.removeValues(hotValues.keySet());
+ return new Histogram(colStats.histogram.dataType, 0, 0,
Collections.emptyList(), hotValues,
+ withoutHotValues == null ? Collections.emptyList() :
withoutHotValues.buckets);
+ }
+ double hotRatio = hotValues.values().stream().mapToDouble(r ->
r).sum();
+ List<Bucket> buckets = hotRatio >= 1 ? Collections.emptyList() :
Lists.newArrayList(new Bucket(
+ colStats.minValue, colStats.maxValue, 1 - hotRatio, 0,
Math.max(1, colStats.ndv - hotValues.size())));
+ Type dataType = colStats.minExpr != null ? colStats.minExpr.getType()
: Type.NULL;
+ return new Histogram(dataType, 0, 0, Collections.emptyList(),
hotValues, buckets);
+ }
+
+ public boolean hasMcv() {
+ return !mcv.isEmpty();
+ }
+
+ /** True if a multi-ndv bucket collapses to equal bounds (e.g. ints beyond
2^53). */
+ public boolean hasCollapsedBuckets() {
+ for (Bucket bucket : Iterables.concat(buckets, mcvBuckets)) {
+ if (bucket.ndv > 1 && bucket.upper <= bucket.lower) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Restrict histogram to [lower, upper]; open interval when {@code
inclusive} is false. */
+ public Histogram intersectRange(double lower, double upper, boolean
inclusive) {
+ DataType type = getDataType();
+ if (!inclusive) {
+ lower = nextValueAbove(lower, type);
+ upper = nextValueBelow(upper, type);
+ }
+ return rebuild(intersectBuckets(buckets, lower, upper, type),
mcvInRange(lower, upper),
+ intersectBuckets(mcvBuckets, lower, upper, type));
+ }
+
+ private static double nextValueAbove(double value, DataType type) {
+ return type.isIntegralType() ? value + 1 : Math.nextUp(value);
+ }
+
+ private static double nextValueBelow(double value, DataType type) {
+ return type.isIntegralType() ? value - 1 : Math.nextDown(value);
+ }
+
+ private Map<Literal, Float> mcvInRange(double lower, double upper) {
+ Map<Literal, Float> result = Maps.newLinkedHashMap();
+ for (Map.Entry<Literal, Float> entry : mcv.entrySet()) {
+ double value = entry.getKey().getDouble();
+ if (value >= lower && value <= upper) {
+ result.put(entry.getKey(), entry.getValue());
+ }
+ }
+ return result;
+ }
+
+ private static List<Bucket> intersectBuckets(List<Bucket> source, double
lower, double upper, DataType type) {
+ List<Bucket> result = Lists.newArrayList();
+ for (Bucket bucket : source) {
+ double newLower = Math.max(bucket.lower, lower);
+ double newUpper = Math.min(bucket.upper, upper);
+ if (newLower > newUpper) {
+ continue;
+ }
+ double fraction = bucket.coveredFraction(newLower, newUpper, type);
+ result.add(new Bucket(newLower, newUpper, bucket.count * fraction,
0, bucket.ndv * fraction));
+ }
+ return result;
+ }
+
+ /** Drop the given values from MCV/buckets. Null if nothing remains. */
+ public Histogram removeValues(Collection<Literal> values) {
+ Map<Literal, Float> newMcv = Maps.newLinkedHashMap(mcv);
+ List<Bucket> newBuckets = copyBuckets(buckets);
+ List<Bucket> newMcvBuckets = copyBuckets(mcvBuckets);
+ for (Literal value : values) {
+ removeValue(newBuckets, value.getDouble());
+ Literal key = StatisticsUtil.findHotValueKey(newMcv, value);
+ if (key != null) {
+ newMcv.remove(key);
+ } else {
+ removeValue(newMcvBuckets, value.getDouble());
+ }
+ }
+ return rebuild(newBuckets, newMcv, newMcvBuckets);
+ }
+
+ private static List<Bucket> copyBuckets(List<Bucket> source) {
+ List<Bucket> result = Lists.newArrayList();
+ for (Bucket bucket : source) {
+ result.add(new Bucket(bucket.lower, bucket.upper, bucket.count, 0,
bucket.ndv));
+ }
+ return result;
+ }
+
+ private static void removeValue(List<Bucket> buckets, double value) {
+ for (int i = 0; i < buckets.size(); i++) {
+ Bucket bucket = buckets.get(i);
+ if (value < bucket.lower || value > bucket.upper) {
+ continue;
+ }
+ if (bucket.lower == bucket.upper || bucket.ndv <= 1) {
+ buckets.remove(i);
+ } else {
+ bucket.count -= bucket.count / bucket.ndv;
Review Comment:
[P1] Do not leave an excluded interior value inside the bucket bounds. This
decrements count/NDV only; later `getValueSelectivity` and join overlap still
treat every value between the unchanged endpoints as present. For example:
```
Join(a.k = b.k)
├─ Filter(NOT(a.k = 5))
│ └─ Scan A(bucket 1..10)
└─ Scan B(b.k = 5)
```
The upper join assigns positive mass to an impossible value. Split the
bucket around the removed point when the type supports it, or invalidate the
histogram when an exact hole cannot be represented.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/stats/JoinEstimation.java:
##########
@@ -595,65 +783,21 @@ private static void
updateJoinConditionColumnStatistics(Statistics inputStats, J
}
if (joinType.isInnerJoin() || joinType.isAsofInnerJoin()) {
ColumnStatisticBuilder builder = new
ColumnStatisticBuilder(leftColStats);
- builder.setNdv(Math.min(leftColStats.ndv, rightColStats.ndv));
- // update hot values
- if (leftColStats.getHotValues() != null &&
rightColStats.getHotValues() != null) {
- Map<Literal, Float> newHotValues = Maps.newHashMap();
- for (Literal literal :
leftColStats.getHotValues().keySet()) {
- if (rightColStats.getHotValues().containsKey(literal))
{
- newHotValues.put(literal,
Math.min(leftColStats.getHotValues().get(literal),
-
rightColStats.getHotValues().get(literal)));
- }
- }
- if (newHotValues.isEmpty()) {
- builder.setHotValues(null);
- } else {
- builder.setHotValues(newHotValues);
- }
- }
- updatedCols.put(eqLeft, builder.build());
- updatedCols.put(eqRight, builder.build());
+ mergeJoinKeyStatistics(leftColStats, rightColStats, builder);
Review Comment:
[P1] Publish the correct output null mass for both equality modes. The
builder inherits left `numNulls`: for ordinary `=`, those rows were excluded
and the joined keys must have zero nulls; for `<=>`, cardinality includes the
null-null product, but the output records only the left input count instead of
the pair mass. This compounds in chained plans:
```
Join(j.k = C.k) Join(j.k <=> C.k)
├─ Join(A.k = B.k) ├─ Join(A.k <=> B.k)
└─ Scan C └─ Scan C
```
The next selectivity applies an invented non-null reduction in the first
case and can underestimate all-null chains by orders of magnitude in the
second. Pass the predicate mode and derived output cardinality/null-pair count
into the merge.
--
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]