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


##########
common/unsafe/src/main/java/org/apache/spark/unsafe/types/UTF8String.java:
##########
@@ -194,6 +205,74 @@ public static UTF8String blankString(int length) {
     return fromBytes(spaces);
   }
 
+  /**
+   * Creates an UTF8String holding the base-10 representation of a long, 
equivalent to
+   * `fromString(Long.toString(value))` but without allocating an intermediate 
`java.lang.String`.
+   * Callers with narrower integral types (byte, short, int) can widen to long 
without changing the
+   * result.
+   */
+  public static UTF8String fromLong(long value) {
+    if (value == 0) {
+      return ZERO_UTF8;

Review Comment:
   `fromLong(0)` (and `fromBoolean` below) now return JVM-wide shared 
instances, and `getBytes()` hands out their backing `byte[]` without copying. 
Previously, each row got a fresh buffer. For example, in `cast(cast(b AS 
STRING) AS BINARY)` the resulting `byte[]` would be the internal array of 
`TRUE_UTF8`, so any downstream code that mutates the binary in place (e.g., a 
UDF on `Array[Byte]`) would corrupt every later `cast(true AS STRING)` in the 
same JVM.
   
   There is precedent (`hex(0)` and `toBinaryString(0)` already return 
`ZERO_UTF8`), so this may be acceptable. Could you confirm that this is 
intended?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala:
##########
@@ -353,6 +360,12 @@ trait ToStringBase { self: UnaryExpression with 
TimeZoneAwareExpression =>
         (c, evPrim) => code"$evPrim = 
UTF8String.fromString($c.toPlainString());"
       case _: StringType =>
         (c, evPrim) => code"$evPrim = $c;"
+      // Format integral and boolean values directly into a UTF8String, 
avoiding an intermediate
+      // java.lang.String. byte/short/int widen to long in the generated code.
+      case ByteType | ShortType | IntegerType | LongType =>

Review Comment:
   Could you add expression-level test coverage in `CastSuiteBase`? For 
integral-to-string, it only has `checkCast(123, "123")` and `checkCast(123L, 
"123")` today (boolean is covered by `cast(true, StringType)`). Since 
`checkEvaluation` runs both the interpreted and codegen paths, a few cases like 
`TINYINT`/`SMALLINT`, `0` (shared instance), negative values, `Long.MinValue`, 
and nested `array<tinyint>` / `map<boolean, int>` would pin down both new 
branches, including the byte/short -> long widening in the generated code, 
beyond `UTF8StringSuite`.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala:
##########
@@ -199,6 +199,13 @@ trait ToStringBase { self: UnaryExpression with 
TimeZoneAwareExpression =>
     case _: DecimalType if useDecimalPlainString =>
       acceptAny[Decimal](d => UTF8String.fromString(d.toPlainString))
     case _: StringType => acceptAny[UTF8String](identity[UTF8String])
+    // Format integral and boolean values directly into a UTF8String, avoiding 
an intermediate

Review Comment:
   Follow-up idea (not necessarily for this PR): for nested types, each element 
still materializes a temporary `byte[]` + `UTF8String` via `fromLong` that is 
immediately copied into the `UTF8StringBuilder` and dropped (e.g., the 
Array/Map/Struct branches above such as L121, and `writeArrayToStringBuilder` / 
`writeMapToStringBuilder` / `writeStructToStringBuilder` in codegen). A 
`UTF8StringBuilder.appendLong(long)` that writes the digits directly into the 
builder buffer would remove those allocations too.



##########
common/unsafe/src/main/java/org/apache/spark/unsafe/types/UTF8String.java:
##########
@@ -194,6 +205,74 @@ public static UTF8String blankString(int length) {
     return fromBytes(spaces);
   }
 
+  /**
+   * Creates an UTF8String holding the base-10 representation of a long, 
equivalent to
+   * `fromString(Long.toString(value))` but without allocating an intermediate 
`java.lang.String`.
+   * Callers with narrower integral types (byte, short, int) can widen to long 
without changing the
+   * result.
+   */
+  public static UTF8String fromLong(long value) {

Review Comment:
   Regarding the benchmark in the PR description: with whole-stage codegen off, 
`cast(cast(id + 10000000 as int) as string)` goes from 31.4 to 33.1-33.4 ns/row 
(~5-6% slower), while the description says the 8-digit case is within noise. 
Since there is a single "before" sample, could you re-measure it? If the 
regression is real, an int-specialized path (like `Integer.toString` vs 
`Long.toString` in the JDK) might recover it, because byte/short/int values 
currently go through the long-based `digitCount` and the long loop check.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala:
##########
@@ -199,6 +199,13 @@ trait ToStringBase { self: UnaryExpression with 
TimeZoneAwareExpression =>
     case _: DecimalType if useDecimalPlainString =>
       acceptAny[Decimal](d => UTF8String.fromString(d.toPlainString))
     case _: StringType => acceptAny[UTF8String](identity[UTF8String])
+    // Format integral and boolean values directly into a UTF8String, avoiding 
an intermediate
+    // java.lang.String. byte/short/int widen to long without changing the 
decimal representation.
+    case ByteType => acceptAny[Byte](b => UTF8String.fromLong(b.toLong))
+    case ShortType => acceptAny[Short](s => UTF8String.fromLong(s.toLong))
+    case IntegerType => acceptAny[Int](i => UTF8String.fromLong(i.toLong))
+    case LongType => acceptAny[Long](l => UTF8String.fromLong(l))

Review Comment:
   This changes the interpreted path from the type-agnostic `o.toString` to 
strict unboxing (`BoxesRunTime.unboxToShort`, etc.). If a value's boxed type 
does not match the declared type (e.g., a `java.lang.Integer` in a `ShortType` 
slot of a hand-built `GenericInternalRow`), this now throws 
`ClassCastException`, while it printed `1` before. Arguably that is a contract 
violation, and `DateType`/`TimestampType` already unbox the same way, but the 
four integral cases can be collapsed into one (matching the codegen side at 
L365) while keeping the old tolerance:
   
   ```suggestion
       case ByteType | ShortType | IntegerType | LongType =>
         acceptAny[java.lang.Number](n => UTF8String.fromLong(n.longValue))
   ```



##########
common/unsafe/src/main/java/org/apache/spark/unsafe/types/UTF8String.java:
##########
@@ -194,6 +205,74 @@ public static UTF8String blankString(int length) {
     return fromBytes(spaces);
   }
 
+  /**
+   * Creates an UTF8String holding the base-10 representation of a long, 
equivalent to
+   * `fromString(Long.toString(value))` but without allocating an intermediate 
`java.lang.String`.
+   * Callers with narrower integral types (byte, short, int) can widen to long 
without changing the
+   * result.
+   */
+  public static UTF8String fromLong(long value) {
+    if (value == 0) {
+      return ZERO_UTF8;
+    }
+    boolean negative = value < 0;
+    // Extract digits in negative space so that Long.MIN_VALUE is 
representable (it cannot be
+    // negated). For a negative v and q = v / 100, `q * 100 - v` is the 
remainder in [0, 99].
+    long v = negative ? value : -value;
+    byte[] bytes = new byte[digitCount(v) + (negative ? 1 : 0)];
+    int pos = bytes.length;
+    // Write two digits per division: in long arithmetic until the value fits 
in an int, then in
+    // cheaper int arithmetic.
+    while (v <= Integer.MIN_VALUE) {
+      long q = v / 100;
+      int r = (int) (q * 100 - v);
+      v = q;
+      bytes[--pos] = DIGIT_PAIRS[2 * r + 1];
+      bytes[--pos] = DIGIT_PAIRS[2 * r];
+    }
+    int iv = (int) v;
+    while (iv <= -100) {
+      int q = iv / 100;
+      int r = q * 100 - iv;
+      iv = q;
+      bytes[--pos] = DIGIT_PAIRS[2 * r + 1];
+      bytes[--pos] = DIGIT_PAIRS[2 * r];
+    }
+    // One or two digits are left, iv is in [-99, -1].
+    if (iv <= -10) {
+      bytes[--pos] = DIGIT_PAIRS[-2 * iv + 1];
+      bytes[--pos] = DIGIT_PAIRS[-2 * iv];
+    } else {
+      bytes[--pos] = (byte) ('0' - iv);
+    }
+    if (negative) {
+      bytes[0] = (byte) '-';
+    }
+    return fromBytes(bytes);

Review Comment:
   nit: the result is always ASCII (and hence valid UTF-8), but `fromBytes` 
leaves `isFullAscii` / `isValid` as `UNKNOWN`, so later calls such as 
`substring`, `getChar`, `toUpperCase`, or `isValid()` rescan the bytes. 
Pre-setting `FULL_ASCII` / `IS_VALID` here would save that work.



##########
common/unsafe/src/test/java/org/apache/spark/unsafe/types/UTF8StringSuite.java:
##########
@@ -1610,6 +1610,53 @@ public void toBinaryString() {
       UTF8String.toBinaryString(Long.MAX_VALUE));
   }
 
+  @Test
+  public void fromLong() {
+    // Integer.MIN_VALUE and Long.MIN_VALUE are the critical cases: they 
cannot be negated, which
+    // is why fromLong extracts digits in negative space. Check their exact 
expected text.
+    assertEquals(fromString("-2147483648"), 
UTF8String.fromLong(Integer.MIN_VALUE));
+    assertEquals(fromString("-9223372036854775808"), 
UTF8String.fromLong(Long.MIN_VALUE));
+    assertEquals(fromString("2147483647"), 
UTF8String.fromLong(Integer.MAX_VALUE));
+    assertEquals(fromString("9223372036854775807"), 
UTF8String.fromLong(Long.MAX_VALUE));
+
+    // Spot-check the boundaries the digit loops care about: zero, sign, the 
switch from long to
+    // int arithmetic around Integer.MIN_VALUE, and the values that cannot be 
negated.
+    long[] boundaries = {
+      0L, 1L, -1L, 9L, -9L, 10L, -10L, 99L, -99L, 100L, -100L, 999L, -999L,
+      Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE + 1L, 
Integer.MIN_VALUE - 1L,
+      10000000000L, -10000000000L, Long.MAX_VALUE, Long.MIN_VALUE, 
Long.MIN_VALUE + 1
+    };
+    for (long value : boundaries) {
+      assertEquals(fromString(String.valueOf(value)), 
UTF8String.fromLong(value));
+    }
+    // Every digit-count change: 10^k - 1 and 10^k, with both signs.
+    long p = 1;
+    for (int k = 1; k <= 18; k++) {
+      p *= 10;
+      for (long value : new long[] {p - 1, p, -(p - 1), -p}) {
+        assertEquals(fromString(String.valueOf(value)), 
UTF8String.fromLong(value));
+      }
+    }
+    // Sweep a contiguous range around zero to exercise every small magnitude 
and both signs.
+    for (long value = -100000L; value <= 100000L; value++) {
+      assertEquals(fromString(String.valueOf(value)), 
UTF8String.fromLong(value));
+    }
+    // Random values across the whole long range.
+    Random random = new Random(42);
+    for (int i = 0; i < 100000; i++) {
+      long value = random.nextLong();
+      assertEquals(fromString(String.valueOf(value)), 
UTF8String.fromLong(value));
+    }
+  }
+
+  @Test
+  public void fromBoolean() {
+    assertEquals(fromString("true"), UTF8String.fromBoolean(true));
+    assertEquals(fromString("false"), UTF8String.fromBoolean(false));
+    assertEquals(fromString(String.valueOf(true)), 
UTF8String.fromBoolean(true));
+    assertEquals(fromString(String.valueOf(false)), 
UTF8String.fromBoolean(false));

Review Comment:
   nit: these two lines repeat the assertions above (`String.valueOf(true)` is 
`"true"`). Similarly, in `fromLong`, the `boundaries` array overlaps with the 
first block and the `10^k` loop. Meanwhile, the documented shared-instance 
behavior is not checked. How about asserting it instead, e.g., 
`assertSame(UTF8String.fromBoolean(true), UTF8String.fromBoolean(true))` and 
`assertSame(UTF8String.ZERO_UTF8, UTF8String.fromLong(0))`?



##########
common/unsafe/src/main/java/org/apache/spark/unsafe/types/UTF8String.java:
##########
@@ -194,6 +205,74 @@ public static UTF8String blankString(int length) {
     return fromBytes(spaces);
   }
 
+  /**
+   * Creates an UTF8String holding the base-10 representation of a long, 
equivalent to

Review Comment:
   nit: the Javadoc says this is equivalent to 
`fromString(Long.toString(value))`, but `fromLong(0)` returns the shared 
`ZERO_UTF8` instance. `fromBoolean` documents its shared-instance behavior, so 
could we document it here too, so that callers don't assume they own the 
returned buffer?



##########
common/unsafe/src/main/java/org/apache/spark/unsafe/types/UTF8String.java:
##########
@@ -194,6 +205,74 @@ public static UTF8String blankString(int length) {
     return fromBytes(spaces);
   }
 
+  /**
+   * Creates an UTF8String holding the base-10 representation of a long, 
equivalent to
+   * `fromString(Long.toString(value))` but without allocating an intermediate 
`java.lang.String`.
+   * Callers with narrower integral types (byte, short, int) can widen to long 
without changing the
+   * result.
+   */
+  public static UTF8String fromLong(long value) {
+    if (value == 0) {
+      return ZERO_UTF8;
+    }
+    boolean negative = value < 0;
+    // Extract digits in negative space so that Long.MIN_VALUE is 
representable (it cannot be
+    // negated). For a negative v and q = v / 100, `q * 100 - v` is the 
remainder in [0, 99].
+    long v = negative ? value : -value;
+    byte[] bytes = new byte[digitCount(v) + (negative ? 1 : 0)];
+    int pos = bytes.length;
+    // Write two digits per division: in long arithmetic until the value fits 
in an int, then in
+    // cheaper int arithmetic.
+    while (v <= Integer.MIN_VALUE) {
+      long q = v / 100;
+      int r = (int) (q * 100 - v);
+      v = q;
+      bytes[--pos] = DIGIT_PAIRS[2 * r + 1];
+      bytes[--pos] = DIGIT_PAIRS[2 * r];
+    }
+    int iv = (int) v;
+    while (iv <= -100) {
+      int q = iv / 100;
+      int r = q * 100 - iv;
+      iv = q;
+      bytes[--pos] = DIGIT_PAIRS[2 * r + 1];
+      bytes[--pos] = DIGIT_PAIRS[2 * r];
+    }
+    // One or two digits are left, iv is in [-99, -1].
+    if (iv <= -10) {
+      bytes[--pos] = DIGIT_PAIRS[-2 * iv + 1];
+      bytes[--pos] = DIGIT_PAIRS[-2 * iv];
+    } else {
+      bytes[--pos] = (byte) ('0' - iv);
+    }
+    if (negative) {
+      bytes[0] = (byte) '-';
+    }
+    return fromBytes(bytes);
+  }
+
+  /**
+   * Returns the number of decimal digits of a negative long, without dividing.
+   */
+  private static int digitCount(long negativeValue) {
+    long bound = -10;
+    for (int digits = 1; digits < 19; digits++) {
+      if (negativeValue > bound) {
+        return digits;
+      }
+      bound *= 10;

Review Comment:
   nit: on the last iteration (`digits == 18`), `bound *= 10` computes 
`-10^19`, which overflows and wraps to a positive value. It is harmless today 
because the loop exits right after, but it becomes a trap if the loop bound 
changes later. Checking before multiplying, or using a static powers-of-ten 
table, would avoid it.



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