HyukjinKwon commented on code in PR #59214:
URL: https://github.com/apache/spark/pull/59214#discussion_r4217837901
##########
python/pyspark/sql/variant_utils.py:
##########
@@ -413,10 +417,105 @@ def handle_array(value_pos_list: List[int]) -> str:
if isinstance(value, bytes):
# decoding simply converts byte array to string
return '"' + base64.b64encode(value).decode("utf-8") + '"'
- if isinstance(value, (datetime.date, datetime.datetime)):
+ if isinstance(value, float):
+ return cls._float_to_json(value, is_float32)
+ if isinstance(value, decimal.Decimal):
+ return cls._decimal_to_json(value)
+ if isinstance(value, datetime.datetime):
+ return '"' + cls._timestamp_to_json(value) + '"'
+ if isinstance(value, datetime.date):
return '"' + str(value) + '"'
return str(value)
+ # The helpers below format scalars the same way as the JVM
`Variant.toJson` on JDK 19+, so
+ # that `VariantVal.toJson` and the `to_json` SQL function produce the same
output. On older
+ # JDKs, `to_json` can print a longer, still round-tripping digit string
for some doubles and
+ # floats (see `_float_to_json`).
+
+ @classmethod
+ def _float_to_json(cls, f: float, is_float32: bool) -> str:
+ """
+ Formats a double or float like Java's `Double.toString` and
`Float.toString` on JDK 19+,
+ which print the shortest digits (JDK-4511638). Older JDKs can print a
longer, still
+ round-tripping digit string for some values, e.g. 2^-24 as
5.9604644775390625E-8 instead
+ of 5.960464477539063E-8. Non-finite values are quoted because they are
not valid JSON
+ numbers.
+ """
+ if math.isnan(f):
+ return '"NaN"'
+ if math.isinf(f):
+ return '"Infinity"' if f > 0 else '"-Infinity"'
+
+ def round_trips(s: str) -> bool:
+ if not is_float32:
+ return float(s) == f
+ try:
+ return struct.pack("<f", float(s)) == struct.pack("<f", f)
Review Comment:
**Nit (P3):** This decides float32 membership by rounding the candidate to a
double first and then to float32. When the decimal is within half a double ulp
of the midpoint between two float32 values, `float(s)` lands exactly on that
midpoint (every float32 midpoint is an exact double). `struct.pack` then ties
to the even neighbor, so a shorter decimal that really rounds to the *other*
float32 gets accepted.
Checked against Temurin JDK 25:
| float32 bits | this PR | JDK 19+ `Float.toString` |
|---|---|---|
| `0x15ae43fe` | `7.038531E-26` | `7.0385313E-26` |
| `0x15ae43fd` | `7.0385307E-26` | `7.038531E-26` |
`Float.parseFloat("7.038531E-26")` returns `0x15ae43fd`, so the first string
names the neighbor. It's rare: no case in 300k random floats, and these two
came from a targeted search near midpoints. The output is still valid JSON, but
`to_json` parity and the float32 round-trip are lost for these values.
Rounding to a double can only cross the true answer when `float(s)` is
exactly a float32 midpoint, so resolving that one tie exactly fixes the whole
class. For example, compare `decimal.Decimal(s)` with
`decimal.Decimal(float(s))`, or use `fractions.Fraction`, to pick the side.
**Verification:**
- **Regression:** The two float32 values next to a midpoint (bits 0x15ae43fe
and 0x15ae43fd) format to the JDK 19+ Float.toString strings.
- **Compatibility:** The existing JVM-format and test_variant_type
expectations still pass unchanged: non-finite values, signed zero, plain/E
thresholds, powers of two in both signs, Float.MAX_VALUE, decimals and
timestamps.
##########
python/pyspark/sql/variant_utils.py:
##########
@@ -413,10 +417,105 @@ def handle_array(value_pos_list: List[int]) -> str:
if isinstance(value, bytes):
# decoding simply converts byte array to string
return '"' + base64.b64encode(value).decode("utf-8") + '"'
- if isinstance(value, (datetime.date, datetime.datetime)):
+ if isinstance(value, float):
+ return cls._float_to_json(value, is_float32)
+ if isinstance(value, decimal.Decimal):
+ return cls._decimal_to_json(value)
+ if isinstance(value, datetime.datetime):
+ return '"' + cls._timestamp_to_json(value) + '"'
+ if isinstance(value, datetime.date):
return '"' + str(value) + '"'
return str(value)
+ # The helpers below format scalars the same way as the JVM
`Variant.toJson` on JDK 19+, so
+ # that `VariantVal.toJson` and the `to_json` SQL function produce the same
output. On older
+ # JDKs, `to_json` can print a longer, still round-tripping digit string
for some doubles and
+ # floats (see `_float_to_json`).
+
+ @classmethod
+ def _float_to_json(cls, f: float, is_float32: bool) -> str:
+ """
+ Formats a double or float like Java's `Double.toString` and
`Float.toString` on JDK 19+,
+ which print the shortest digits (JDK-4511638). Older JDKs can print a
longer, still
+ round-tripping digit string for some values, e.g. 2^-24 as
5.9604644775390625E-8 instead
+ of 5.960464477539063E-8. Non-finite values are quoted because they are
not valid JSON
+ numbers.
+ """
+ if math.isnan(f):
+ return '"NaN"'
+ if math.isinf(f):
+ return '"Infinity"' if f > 0 else '"-Infinity"'
+
+ def round_trips(s: str) -> bool:
+ if not is_float32:
+ return float(s) == f
+ try:
+ return struct.pack("<f", float(s)) == struct.pack("<f", f)
+ except OverflowError:
+ return False
+
+ # Use an explicit context so that the result does not depend on the
caller's decimal
+ # context, e.g. a lowered `decimal.getcontext().prec`.
+ context = decimal.Context(prec=VariantUtils.MAX_DECIMAL16_PRECISION)
+
+ # Like Java, pick the shortest decimal that round-trips, using at
least 2 significant
+ # digits (e.g. Double.MIN_VALUE is "4.9E-324", not "5.0E-324"), and
the one closest to
+ # `f` among those of that length.
+ for precision in range(2, 18):
Review Comment:
**Nit (P3):** Every double now runs up to 16 `%e` format/parse steps, and
each failed step also builds `Decimal`s for the power-of-two retry. On this
head, 20k random doubles take about 32 us each, versus about 0.4 us with the
previous `repr()`. So `toJson()`/`str()` on variants with many doubles (e.g.
called per row in a UDF) gets roughly 75x slower for that part.
Two shortcuts keep the output identical:
- `repr(f)` is already the shortest round-tripping decimal, so for doubles
the loop can start at `max(2, <significant digits of repr(f)>)`.
- The away-from-zero retry can only succeed when `f` is a power of two
(otherwise the rounding interval is symmetric), so it can be gated on
`abs(math.frexp(f)[0]) == 0.5`.
With both, output was byte-identical on 44k doubles (random bit patterns,
every power of two in both signs, the 1e-3/1e7 boundaries) and 20k float32
values, at about 4 us per double.
**Verification:**
- **Compatibility:** The new formatter output equals the current output on a
broad sample: random doubles and float32 values, every power of two in both
formats and signs, and the 1e-3/1e7 boundaries. All existing JVM-format and
test_variant_type expectations pass unchanged.
- **Inspection:** A timing comparison on a batch of random doubles shows
per-value formatting cost substantially below the current implementation.
##########
python/pyspark/sql/tests/test_types.py:
##########
@@ -3864,6 +3864,70 @@ def test_struct_field_type_name(self):
struct_field = StructField("a", IntegerType())
self.assertRaises(TypeError, struct_field.typeName)
+ def test_variant_to_json_matches_jvm_format(self):
+ import decimal
+ import struct
+
+ def variant(type_info, payload):
+ return VariantVal(bytes([type_info << 2]) + payload, bytes([1, 0,
0]))
+
+ def double(x):
+ return variant(7, struct.pack("<d", x))
+
+ def float32(x):
+ return variant(14, struct.pack("<f", x))
+
+ def decimal4(unscaled, scale):
+ return variant(8, bytes([scale]) + struct.pack("<i", unscaled))
+
+ def timestamp(micros, ntz):
+ return variant(13 if ntz else 12, struct.pack("<q", micros))
+
+ # The expected values are what the JVM `to_json` returns for the same
variants on JDK 19+.
+ # On older JDKs, `to_json` can print longer digit strings for some
doubles and floats,
+ # e.g. the powers of two below.
+ for v, expected in [
+ (double(float("nan")), '"NaN"'),
+ (double(float("inf")), '"Infinity"'),
+ (double(float("-inf")), '"-Infinity"'),
+ (double(0.0), "0.0"),
+ (double(-0.0), "-0.0"),
+ (double(123.0), "123.0"),
+ (double(123456.789), "123456.789"),
+ (double(0.001), "0.001"),
+ (double(1e7), "1.0E7"),
+ (double(1.5e-5), "1.5E-5"),
+ (double(-1e20), "-1.0E20"),
+ (double(5e-324), "4.9E-324"),
+ # Powers of two whose shortest decimal is not the closest one of
that length.
+ (double(2.0**-24), "5.960464477539063E-8"),
+ (double(-(2.0**-24)), "-5.960464477539063E-8"),
+ (double(2.0**-44), "5.684341886080802E-14"),
+ (float32(2.0**-96), "1.2621775E-29"),
+ (float32(-(2.0**-96)), "-1.2621775E-29"),
+ (float32(float("nan")), '"NaN"'),
+ (float32(1.1), "1.1"),
+ (float32(1e10), "1.0E10"),
+ (float32(3.4028234663852886e38), "3.4028235E38"),
+ (decimal4(100, 2), "1"),
+ (decimal4(0, 2), "0"),
+ (decimal4(-1, 9), "-0.000000001"),
+ (timestamp(1_500_000, ntz=True), '"1970-01-01 00:00:01.5"'),
+ (timestamp(1, ntz=False), '"1970-01-01 00:00:00.000001+00:00"'),
+ ]:
+ with self.subTest(expected=expected):
+ self.assertEqual(v.toJson(), expected)
+ # The output must always be valid JSON.
+ json.loads(v.toJson())
+ # The output must not depend on the caller's decimal context.
+ with decimal.localcontext() as ctx:
+ ctx.prec = 5
+ self.assertEqual(v.toJson(), expected)
+
+ self.assertEqual(
+ timestamp(0, ntz=False).toJson("Asia/Kolkata"), '"1970-01-01
05:30:00+05:30"'
Review Comment:
**Nit (P3):** Every offset asserted here is a whole number of minutes
(`+00:00`, `-08:00`, `+05:30`), so the seconds-dropping rule this PR adds for
LMT offsets isn't covered. Printing the seconds again, or rounding instead of
truncating, would still pass. Similarly, the longest double expectation has 16
significant digits, so nothing reaches the 17-digit end of `range(2, 18)`.
Two vectors would pin both:
- 1800-01-01T00:00Z with `toJson("America/Los_Angeles")`, expecting
`"1799-12-31 16:07:02-07:52"` (the UTC offset there is -07:52:58).
- `double(0.1 + 0.2)`, expecting `"0.30000000000000004"`.
**Verification:**
- **Regression:** The test fails if _timestamp_to_json prints offset seconds
or rounds instead of truncating, and fails if the double digit search cannot
reach 17 digits.
- **Behavior:** The new vectors pass on the current implementation.
--
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]