[
https://issues.apache.org/jira/browse/SPARK-60202?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Varun Bhandary updated SPARK-60202:
-----------------------------------
Description:
h3. Problem and reproduction
I found that two-argument {{floor}} and {{ceil}} can round floating-point
inputs in the wrong direction while investigating rounding behavior. Catalyst
implicitly casts Double to {{decimal(30,15)}} and Float to {{decimal(14,7)}}
before directional rounding. That cast can move an input across the requested
boundary.
{code:sql}
SELECT
floor(CAST('0.9999999999999999' AS DOUBLE)) AS unary_floor,
floor(CAST('0.9999999999999999' AS DOUBLE), 0) AS scaled_floor,
ceil(CAST('0.9000000000000001' AS DOUBLE), 1) AS scaled_ceil;
{code}
Current numerical results: {{(0, 1, 0.9)}}. Expected: {{(0, 0, 1.0)}}. Unary
floor remains a long; scaled results remain decimals.
|| Explicit Double expression || Current || Expected ||
| {{floor(CAST('0.8999999999999999' AS DOUBLE), 1)}} | 0.9 | 0.8 |
| {{floor(CAST('1.7999999999999998' AS DOUBLE), 1)}} | 1.8 | 1.7 |
The supplied Double values are already on the appropriate side of the boundary;
the intermediate cast changes that. Runtime-column regressions also reproduce
this, so it is not confined to constant folding or Spark Connect. Changed
rounding can affect interval and bucket assignments.
h3. Proposed correction and Spark precedent
Avoid the premature fixed-scale cast locally in
[RoundFloor/RoundCeil|https://github.com/apache/spark/blob/f04e6d60ebf9131fd83f306627697370ef52a309/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/mathExpressions.scala].
Retain existing decimal result precision/scale, explicit casts,
integral/decimal input behavior, null/non-finite handling and input-range
checks. No public API, setting, dependency or global coercion change is
proposed.
Decimal output is intentional: see
[SPARK-38604|https://issues.apache.org/jira/browse/SPARK-38604] and [the
original implementation
discussion|https://github.com/apache/spark/pull/34729#discussion_r789493919].
[SPARK-59373|https://issues.apache.org/jira/browse/SPARK-59373] clarified the
return-type documentation without changing behavior.
The candidate follows Spark's existing {{round}}/{{bround}} conversion
convention: widen Float to Double, then use decimal conversion before
directional rounding at the retained output scale. This is not exact IEEE-value
arithmetic for every input. The Float conversion policy and compatibility
effects below need explicit review.
h3. Compatibility and ANSI considerations
*Ordinary Float inputs can change.* These are observed candidate results, not a
claim that every engine must produce them:
|| Expression || Previous path || Candidate ||
| {{ceil(CAST('0.1' AS FLOAT), 1)}} | 0.1 | 0.2 |
| {{floor(CAST('0.9' AS FLOAT), 1)}} | 0.9 | 0.8 |
The widened Float values lie just above 0.1 and below 0.9. For Double {{0.1}},
the canonical decimal conversion yields {{0.1}}, so ceil at scale 1 remains
{{0.1}}. Explicit decimal casts continue to let callers choose normalization.
*Input-range compatibility does not imply unchanged result overflow.* For
{{ceil(CAST('1e-20' AS DOUBLE), -38)}}, the old cast loses the input to zero.
The candidate instead reaches {{1e38}}, which cannot fit {{decimal(38,0)}}, and
throws in both ANSI modes through the existing decimal negative-scale path;
scale -37 succeeds. [Spark's general ANSI
guidance|https://spark.apache.org/docs/latest/sql-ref-ansi-compliance.html#arithmetic-operations]
describes null on decimal overflow with ANSI disabled. This discrepancy needs
an explicit decision and tests; the proposal does not claim unchanged overflow
outcomes or silently redefine global overflow rules. It is separate from
[SPARK-58052|https://issues.apache.org/jira/browse/SPARK-58052].
*Standards and other engines guide the review, but do not settle this
overload.* [ISO SQL's 2003 corrigendum, section
6.2|https://cdn.standards.iteh.ai/samples/37833/600f10511a6d4d70b305701fd69ed417/ISO-IEC-9075-2-1999-Amd-1-2001-Cor-1-2003.pdf]
specifies unary floor/ceiling direction and an exception for unrepresentable
results, with different result categories for exact and approximate inputs. It
does not establish Spark's scaled Decimal-returning contract. [Snowflake's
scaled CEIL|https://docs.snowflake.com/en/sql-reference/functions/ceil] returns
FLOAT for FLOAT input, unlike Spark. This is a focused correctness proposal,
not a claim of ANSI certification or identical floating-point results across
engines.
h3. Validation and remaining review work
Recorded Catalyst reproductions cover 4.0.2 and 4.1.2. On master
{{f04e6d60ebf9131fd83f306627697370ef52a309}}, two new regressions fail before
the fix and pass afterward. Four relevant suites pass (242 tests), including
runtime SQL with code generation and ANSI each enabled/disabled; three style
checks pass. Local tests used a temporary Netty 4.2.18 override because the
proxy blocks 4.2.19; no dependency files changed. Default-dependency CI results
are tracked on the linked PR.
[PR #59408|https://github.com/apache/spark/pull/59408] contains the candidate.
Follow-up remains to add ordinary-Float and result-overflow regressions in both
ANSI/execution modes, independent expected-schema assertions, and a [migration
note|https://spark.apache.org/contributing.html#behavior-changes] explaining
changed results and potential recomputation of persisted buckets. The Float
policy and ANSI-disabled overflow behavior remain review decisions.
was:
h3. Problem
The two-argument {{floor}} and {{ceil}} can return a value on the wrong side of
their floating-point input. They implicitly convert Double to
{{decimal(30,15)}} and Float to {{decimal(14,7)}} before applying directional
rounding. The conversion can round across the very boundary that floor/ceil
should preserve.
h3. Reproduction
For example, with explicit Double inputs:
{code:sql}
SELECT
floor(CAST('0.9999999999999999' AS DOUBLE)) AS unary_floor,
floor(CAST('0.9999999999999999' AS DOUBLE), 0) AS scaled_floor,
ceil(CAST('0.9000000000000001' AS DOUBLE), 1) AS scaled_ceil;
{code}
The current results are {{(0, 1, 0.9)}}. The expected numerical results are
{{(0, 0, 1.0)}}. The unary result remains a long and the scaled results remain
decimals; this report does not propose making their types identical.
|| Explicit Double expression || Current || Expected ||
| {{floor(0.8999999999999999, 1)}} | 0.9 | 0.8 |
| {{floor(1.7999999999999998, 1)}} | 1.8 | 1.7 |
| {{floor(0.9999999999999999, 0)}} | 1 | 0 |
| {{ceil(0.9000000000000001, 1)}} | 0.9 | 1.0 |
This affects directional rounding and interval/bucket boundaries, including
values obtained from calculations rather than SQL literals. Runtime-column
regressions reproduce the problem without relying on constant folding.
h3. Cause and proposed correction
The cause is in Catalyst's [RoundFloor and RoundCeil
implementation|https://github.com/apache/spark/blob/f04e6d60ebf9131fd83f306627697370ef52a309/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/mathExpressions.scala]:
both require decimal input, so analysis inserts the lossy cast before
rounding. This is distinct from ordinary binary floating-point representation
error: the supplied Double values are already on the correct side of the
boundary, but the intermediate cast moves them across it.
The proposed correction is local to scaled floor/ceil: accept Float/Double
directly and defer rounding to the requested output scale. Preserve existing
decimal result precision/scale, explicit user casts, integral/decimal input
behavior, and established null, non-finite and ANSI overflow behavior. Do not
change global numeric-to-decimal coercion.
Float results can also change. For example, {{3.1415f}} represents
{{3.141499996185302734375}}, so floor at scale 4 should produce {{3.1414}}, not
{{3.1415}}. The proposal does not expand the existing maximum output scales or
promise exact binary arithmetic for every input; it retains Spark's existing
Double-to-Decimal conversion convention while removing the premature scale
reduction.
h3. Related work
The decimal return type is intentional, as discussed in
[SPARK-38604|https://issues.apache.org/jira/browse/SPARK-38604] and [the
original scale
implementation|https://github.com/apache/spark/pull/34729#discussion_r789493919].
[SPARK-59373|https://issues.apache.org/jira/browse/SPARK-59373] clarified that
contract without changing behavior. This precision problem is separate from the
overflow work in
[SPARK-58052|https://issues.apache.org/jira/browse/SPARK-58052].
h3. Validation
Recorded Catalyst reproductions cover released 4.0.2 and 4.1.2. On master
{{f04e6d60ebf9131fd83f306627697370ef52a309}}, two new regressions fail before
the local fix and pass afterward. Four relevant suites pass (242 tests),
including SQL runtime columns with code generation enabled/disabled and ANSI
enabled/disabled. Local builds required a session-only Netty 4.2.18 override
because the configured proxy rejects master's 4.2.19 artifacts as "version too
new"; validation with the exact upstream dependency set remains outstanding.
> Preserve floating-point boundaries in scaled floor and ceil
> -----------------------------------------------------------
>
> Key: SPARK-60202
> URL: https://issues.apache.org/jira/browse/SPARK-60202
> Project: Spark
> Issue Type: Bug
> Components: SQL
> Affects Versions: 4.0.2, 4.1.2
> Reporter: Varun Bhandary
> Priority: Major
> Labels: correctness, pull-request-available
>
> h3. Problem and reproduction
> I found that two-argument {{floor}} and {{ceil}} can round floating-point
> inputs in the wrong direction while investigating rounding behavior. Catalyst
> implicitly casts Double to {{decimal(30,15)}} and Float to {{decimal(14,7)}}
> before directional rounding. That cast can move an input across the requested
> boundary.
> {code:sql}
> SELECT
> floor(CAST('0.9999999999999999' AS DOUBLE)) AS unary_floor,
> floor(CAST('0.9999999999999999' AS DOUBLE), 0) AS scaled_floor,
> ceil(CAST('0.9000000000000001' AS DOUBLE), 1) AS scaled_ceil;
> {code}
> Current numerical results: {{(0, 1, 0.9)}}. Expected: {{(0, 0, 1.0)}}. Unary
> floor remains a long; scaled results remain decimals.
> || Explicit Double expression || Current || Expected ||
> | {{floor(CAST('0.8999999999999999' AS DOUBLE), 1)}} | 0.9 | 0.8 |
> | {{floor(CAST('1.7999999999999998' AS DOUBLE), 1)}} | 1.8 | 1.7 |
> The supplied Double values are already on the appropriate side of the
> boundary; the intermediate cast changes that. Runtime-column regressions also
> reproduce this, so it is not confined to constant folding or Spark Connect.
> Changed rounding can affect interval and bucket assignments.
> h3. Proposed correction and Spark precedent
> Avoid the premature fixed-scale cast locally in
> [RoundFloor/RoundCeil|https://github.com/apache/spark/blob/f04e6d60ebf9131fd83f306627697370ef52a309/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/mathExpressions.scala].
> Retain existing decimal result precision/scale, explicit casts,
> integral/decimal input behavior, null/non-finite handling and input-range
> checks. No public API, setting, dependency or global coercion change is
> proposed.
> Decimal output is intentional: see
> [SPARK-38604|https://issues.apache.org/jira/browse/SPARK-38604] and [the
> original implementation
> discussion|https://github.com/apache/spark/pull/34729#discussion_r789493919].
> [SPARK-59373|https://issues.apache.org/jira/browse/SPARK-59373] clarified the
> return-type documentation without changing behavior.
> The candidate follows Spark's existing {{round}}/{{bround}} conversion
> convention: widen Float to Double, then use decimal conversion before
> directional rounding at the retained output scale. This is not exact
> IEEE-value arithmetic for every input. The Float conversion policy and
> compatibility effects below need explicit review.
> h3. Compatibility and ANSI considerations
> *Ordinary Float inputs can change.* These are observed candidate results, not
> a claim that every engine must produce them:
> || Expression || Previous path || Candidate ||
> | {{ceil(CAST('0.1' AS FLOAT), 1)}} | 0.1 | 0.2 |
> | {{floor(CAST('0.9' AS FLOAT), 1)}} | 0.9 | 0.8 |
> The widened Float values lie just above 0.1 and below 0.9. For Double
> {{0.1}}, the canonical decimal conversion yields {{0.1}}, so ceil at scale 1
> remains {{0.1}}. Explicit decimal casts continue to let callers choose
> normalization.
> *Input-range compatibility does not imply unchanged result overflow.* For
> {{ceil(CAST('1e-20' AS DOUBLE), -38)}}, the old cast loses the input to zero.
> The candidate instead reaches {{1e38}}, which cannot fit {{decimal(38,0)}},
> and throws in both ANSI modes through the existing decimal negative-scale
> path; scale -37 succeeds. [Spark's general ANSI
> guidance|https://spark.apache.org/docs/latest/sql-ref-ansi-compliance.html#arithmetic-operations]
> describes null on decimal overflow with ANSI disabled. This discrepancy
> needs an explicit decision and tests; the proposal does not claim unchanged
> overflow outcomes or silently redefine global overflow rules. It is separate
> from [SPARK-58052|https://issues.apache.org/jira/browse/SPARK-58052].
> *Standards and other engines guide the review, but do not settle this
> overload.* [ISO SQL's 2003 corrigendum, section
> 6.2|https://cdn.standards.iteh.ai/samples/37833/600f10511a6d4d70b305701fd69ed417/ISO-IEC-9075-2-1999-Amd-1-2001-Cor-1-2003.pdf]
> specifies unary floor/ceiling direction and an exception for unrepresentable
> results, with different result categories for exact and approximate inputs.
> It does not establish Spark's scaled Decimal-returning contract. [Snowflake's
> scaled CEIL|https://docs.snowflake.com/en/sql-reference/functions/ceil]
> returns FLOAT for FLOAT input, unlike Spark. This is a focused correctness
> proposal, not a claim of ANSI certification or identical floating-point
> results across engines.
> h3. Validation and remaining review work
> Recorded Catalyst reproductions cover 4.0.2 and 4.1.2. On master
> {{f04e6d60ebf9131fd83f306627697370ef52a309}}, two new regressions fail before
> the fix and pass afterward. Four relevant suites pass (242 tests), including
> runtime SQL with code generation and ANSI each enabled/disabled; three style
> checks pass. Local tests used a temporary Netty 4.2.18 override because the
> proxy blocks 4.2.19; no dependency files changed. Default-dependency CI
> results are tracked on the linked PR.
> [PR #59408|https://github.com/apache/spark/pull/59408] contains the
> candidate. Follow-up remains to add ordinary-Float and result-overflow
> regressions in both ANSI/execution modes, independent expected-schema
> assertions, and a [migration
> note|https://spark.apache.org/contributing.html#behavior-changes] explaining
> changed results and potential recomputation of persisted buckets. The Float
> policy and ANSI-disabled overflow behavior remain review decisions.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]