tdcmeehan opened a new pull request, #262:
URL: https://github.com/apache/spark-connect-rust/pull/262

   ### What changes were proposed in this pull request?
   
   `#[spark_wasm_udf]` functions whose arguments and result are all `i32`, 
`i64`, `f32`, `f64`, `bool`, `String`, `Vec<u8>`, or one `Option` of those are 
now called once per Arrow batch instead of once per row.
   
   - **Macro** (`spark-connect-macros`): for such a function it also emits a 
`spark_batch_udf_<fn>` export, and the generated `udf::<fn>_udf()` builder 
calls `.with_batch_entrypoint(...)`. Other signatures (arrays, nested 
`Option`s) are unchanged. The batch export names use a prefix of their own, so 
they cannot collide with the per-row `spark_udf_<fn>` exports.
   - **Build runtime** (`spark-connect-build`): adds the batch ABI to the 
runtime compiled into every module. The host writes a descriptor and the Arrow 
buffers of each argument column (validity, values, offsets) into a scratch 
buffer (`spark_batch_scratch`). The export evaluates every row and returns a 
descriptor of the result's Arrow buffers, or an error message.
   - **Packer** (`wasm_packer.py`): adds the `WasmArrowUDF` runner. It casts 
each argument to the declared Arrow type, copies the buffers in, calls the 
batch export once, and builds the result array from the returned buffers. Each 
Python worker process keeps one wasmtime engine and compiles each module once, 
keyed by its hash. Each runner gets its own wasmtime instance, which is 
recreated after a trap.
   - **Client** (`wasm_udf.rs`): adds 
`UserDefinedFunction::with_batch_entrypoint`, which sets the eval type to 
`SQL_SCALAR_ARROW_UDF` (250). The eval type decides which runner is packed: the 
batch entrypoint goes into the UDF only under eval type 250. Eval type 250 
without a batch entrypoint, or with zero arguments, is an error on the client.
   - Tests in a new `tests/wasm-udf` crate, excluded from the workspace like 
the WASM UDF examples, plus docs and CI.
   
   ### Why are the changes needed?
   
   Spark's Python worker calls a `SQL_ARROW_BATCHED_UDF` once per row. For a 
WASM UDF, each call encodes the row's arguments, enters the module, and decodes 
the result, which costs about 100 microseconds per row in the measurements 
below. With one call per batch, these UDFs cost about the same as a Python 
`arrow_udf` doing the same work with `pyarrow.compute`.
   
   Measured end to end against a local Spark 4.3.0 Connect server (`--master 
local[1]`, default 10,000-row Arrow batches), in ns per row:
   
   | UDF | built-in expression | per row (today) | per batch (this PR) | Python 
`arrow_udf` (`pyarrow.compute`) |
   |---|---:|---:|---:|---:|
   | `add_one (i64)` | 1.2 / 1.6 | 100,235 | 123.5 | 131.4 |
   | `shout (String)` | 109.2 | 103,467 | 301.9 | 304.2 |
   
   - **Method:** each case runs `range(n, 1 partition).select(f(id)).agg(sum)`, 
with `sum(length(...))` for the string cases. The cost per row is the 
difference between the run times of two row counts divided by the difference in 
rows, so query, worker and module start-up costs cancel out. Each row count 
takes the best of 3 runs after one warm-up run.
   - **Built-ins:** `id + 1` and `concat(upper(cast(id as string)), '!')`.
   - **Runs:** the figures are from the first of two consecutive runs. In the 
second run every figure was within 15%, except the built-in `id + 1` (a 1-2 ns 
figure), so both of its runs are shown. Second run: 102,251 / 125.9 / 127.3 for 
`add_one`, and 101.2 / 97,310 / 303.5 / 294.6 for `shout`.
   - **Machine:** Intel Xeon Platinum 8559C, 4 cores / 8 threads. OpenJDK 
21.0.12.1, Python 3.12.3, pyspark 4.3.0, pyarrow 26.0.0, wasmtime 50.0.1, rustc 
1.99.1.
   
   <details>
   <summary>How to reproduce (the bench and the script are not part of this 
PR)</summary>
   
   Start a Spark 4.3.0 Connect server whose Python workers have `pyarrow` and 
`wasmtime`, for example from a `pyspark==4.3.0` install:
   
   ```bash
   PYSPARK_PYTHON=<python with pyarrow and wasmtime> \
       "$SPARK_HOME/sbin/start-connect-server.sh" --master 'local[1]'
   ```
   
   Put the three bench files in `examples/wasm-udf-bench/`, add 
`"examples/wasm-udf-bench"` to the root `Cargo.toml` `exclude` list, and run 
the bench and then the `arrow_udf` script (needs `pyspark[connect]==4.3.0` and 
`pyarrow`):
   
   ```bash
   rustup target add wasm32-unknown-unknown
   SPARK_CONNECT_PYTHON=<python with pyspark> \
       cargo run --release --manifest-path examples/wasm-udf-bench/Cargo.toml
   python arrow_udf_bench.py
   ```
   
   `examples/wasm-udf-bench/Cargo.toml`:
   
   ```toml
   [workspace]
   
   [package]
   name = "wasm-udf-bench"
   version = "0.0.0"
   edition = "2021"
   publish = false
   
   [dependencies]
   spark-connect-macros = { package = "apache-spark-connect-macros", path = 
"../../crates/spark-connect-macros" }
   
   [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
   spark-connect = { package = "apache-spark-connect", path = 
"../../crates/spark-connect", features = ["wasm-udf"] }
   
   [build-dependencies]
   spark-connect-build = { package = "apache-spark-connect-build", path = 
"../../crates/spark-connect-build" }
   spark-connect-macros = { package = "apache-spark-connect-macros", path = 
"../../crates/spark-connect-macros" }
   ```
   
   `examples/wasm-udf-bench/build.rs`:
   
   ```rust
   fn main() {
       // Compiles this crate's `#[spark_wasm_udf]` functions to wasm32 and 
embeds
       // the module (exposed to the crate as the `WASM_UDFS_MODULE` env var).
       spark_connect_build::embed_wasm_udf("src/main.rs");
   }
   ```
   
   `examples/wasm-udf-bench/src/main.rs`:
   
   ```rust
   //! Rust UDFs per row vs per Arrow batch, end to end through a Spark Connect 
server.
   //!
   //! Each case runs `range(n, 1 partition).select(f(id)).agg(sum)`. The cost 
per row is the
   //! difference between two row counts divided by the difference in rows, so 
query, worker and
   //! module start-up costs cancel out. Best of 3 per row count, after one 
warm-up run.
   //!
   //!     rustup target add wasm32-unknown-unknown
   //!     SPARK_CONNECT_PYTHON=<python with pyspark> \
   //!         cargo run --release --manifest-path 
examples/wasm-udf-bench/Cargo.toml
   
   use spark_connect_macros::spark_wasm_udf;
   
   #[spark_wasm_udf]
   mod udfs {
       pub fn add_one(x: i64) -> i64 {
           x + 1
       }
   
       pub fn shout(s: String) -> String {
           let mut t = s.to_uppercase();
           t.push('!');
           t
       }
   
       pub fn half_if_even(x: Option<i64>) -> Option<i64> {
           x.filter(|v| v % 2 == 0).map(|v| v / 2)
       }
   }
   
   #[cfg(not(target_arch = "wasm32"))]
   fn main() -> Result<(), Box<dyn std::error::Error>> {
       use spark_connect::functions::{col, expr, length, sum};
       use spark_connect::row::Value;
       use spark_connect::udf::eval_type;
       use spark_connect::{Column, SparkSession, SparkSessionBuilder};
       use std::time::Instant;
   
       type BoxErr = Box<dyn std::error::Error>;
   
       let spark: SparkSession = SparkSessionBuilder::default()
           .remote("sc://127.0.0.1:15002/")
           .get_or_create()?;
   
       // Seconds to collect sum(f(id)) (or sum(length(f(id)))) over n ids in 
one partition.
       let run = |f: &dyn Fn() -> Result<Column, BoxErr>,
                  sum_len: bool,
                  n: i64|
        -> Result<(f64, i64), BoxErr> {
           let total = if sum_len {
               sum(length(col("x")))
           } else {
               sum(col("x"))
           };
           let df = spark
               .range_full(0, n, 1, Some(1))?
               .select([f()?.alias("x")])
               .agg(vec![total.expression().clone()]);
           let start = Instant::now();
           let rows = df.collect()?;
           let secs = start.elapsed().as_secs_f64();
           match rows[0].get(0) {
               Some(Value::Long(v)) => Ok((secs, *v)),
               other => Err(format!("unexpected result {other:?}").into()),
           }
       };
   
       let ns_per_row = |name: &str,
                         f: &dyn Fn() -> Result<Column, BoxErr>,
                         sum_len: bool,
                         expected: &dyn Fn(i64) -> i64,
                         n1: i64,
                         n2: i64|
        -> Result<(), BoxErr> {
           run(f, sum_len, n1)?;
           let mut best = [f64::MAX; 2];
           for (k, n) in [n1, n2].into_iter().enumerate() {
               for _ in 0..3 {
                   let (secs, v) = run(f, sum_len, n)?;
                   if v != expected(n) {
                       return Err(
                           format!("{name}: sum {v} for {n} rows, expected {}", 
expected(n)).into(),
                       );
                   }
                   best[k] = best[k].min(secs);
               }
           }
           let ns = (best[1] - best[0]) / (n2 - n1) as f64 * 1e9;
           println!(
               "{name:22} {ns:10.1} ns/row   ({n1:>10} rows {:7.3} s, {n2:>10} 
rows {:7.3} s)",
               best[0], best[1]
           );
           Ok(())
       };
   
       let add_one_sum = |n: i64| n * (n - 1) / 2 + n;
       let shout_len = |n: i64| (0..n).map(|i| i.to_string().len() as i64 + 
1).sum::<i64>();
       let half_sum = |n: i64| (0..n).filter(|i| i % 2 == 0).map(|i| i / 
2).sum::<i64>();
       let id_str = || col("id").cast_str("string");
   
       // Same answers on both paths, including null results.
       let n = 25_000;
       let (_, per_row) = run(
           &|| {
               Ok(udf::half_if_even_udf()
                   .with_eval_type(eval_type::SQL_ARROW_BATCHED_UDF)
                   .call(vec![col("id")])?)
           },
           false,
           n,
       )?;
       let (_, batch) = run(&|| Ok(udf::half_if_even(col("id"))?), false, n)?;
       assert_eq!((per_row, batch), (half_sum(n), half_sum(n)), "half_if_even");
   
       ns_per_row(
           "builtin id + 1",
           &|| Ok(expr("id + 1")),
           false,
           &add_one_sum,
           2_000_000,
           20_000_000,
       )?;
       ns_per_row(
           "add_one per row",
           &|| {
               Ok(udf::add_one_udf()
                   .with_eval_type(eval_type::SQL_ARROW_BATCHED_UDF)
                   .call(vec![col("id")])?)
           },
           false,
           &add_one_sum,
           10_000,
           50_000,
       )?;
       ns_per_row(
           "add_one per batch",
           &|| Ok(udf::add_one(col("id"))?),
           false,
           &add_one_sum,
           2_000_000,
           20_000_000,
       )?;
       ns_per_row(
           "builtin shout",
           &|| Ok(expr("concat(upper(cast(id as string)), '!')")),
           true,
           &shout_len,
           1_000_000,
           5_000_000,
       )?;
       ns_per_row(
           "shout per row",
           &|| {
               Ok(udf::shout_udf()
                   .with_eval_type(eval_type::SQL_ARROW_BATCHED_UDF)
                   .call(vec![id_str()])?)
           },
           true,
           &shout_len,
           10_000,
           50_000,
       )?;
       ns_per_row(
           "shout per batch",
           &|| Ok(udf::shout(id_str())?),
           true,
           &shout_len,
           1_000_000,
           5_000_000,
       )?;
       Ok(())
   }
   ```
   
   `arrow_udf_bench.py`:
   
   ```python
   """Python arrow_udf baselines for the WASM UDF bench, measured the same way.
   
   Each case runs `range(n, 1 partition).select(f(id)).agg(sum)`. The cost per 
row is the
   difference between two row counts divided by the difference in rows, so 
query, worker and
   start-up costs cancel out. Best of 3 per row count, after one warm-up run.
   
       python arrow_udf_bench.py   # Spark Connect server on 
sc://localhost:15002
   """
   
   import time
   
   import pyarrow as pa
   import pyarrow.compute as pc
   from pyspark.sql import SparkSession
   from pyspark.sql.functions import arrow_udf, col, length
   from pyspark.sql.functions import sum as sum_
   
   spark = SparkSession.builder.remote("sc://localhost:15002").getOrCreate()
   
   
   @arrow_udf("long")
   def add_one(x: pa.Array) -> pa.Array:
       return pc.add(x, 1)
   
   
   @arrow_udf("string")
   def shout(s: pa.Array) -> pa.Array:
       return pc.binary_join_element_wise(pc.utf8_upper(s), "!", "")
   
   
   def run(f, sum_len, n):
       df = spark.range(0, n, 1, 1).select(f().alias("x"))
       df = df.agg(sum_(length(col("x"))) if sum_len else sum_(col("x")))
       start = time.perf_counter()
       value = df.collect()[0][0]
       return time.perf_counter() - start, value
   
   
   def ns_per_row(name, f, sum_len, expected, n1, n2):
       run(f, sum_len, n1)
       best = [float("inf")] * 2
       for k, n in enumerate((n1, n2)):
           for _ in range(3):
               secs, v = run(f, sum_len, n)
               assert v == expected(n), f"{name}: sum {v} for {n} rows, 
expected {expected(n)}"
               best[k] = min(best[k], secs)
       ns = (best[1] - best[0]) / (n2 - n1) * 1e9
       print(
           f"{name:22} {ns:10.1f} ns/row   ({n1:>10} rows {best[0]:7.3f} s, "
           f"{n2:>10} rows {best[1]:7.3f} s)"
       )
   
   
   def add_one_sum(n):
       return n * (n - 1) // 2 + n
   
   
   def shout_len(n):
       return sum(len(str(i)) + 1 for i in range(n))
   
   
   ns_per_row(
       "add_one arrow_udf", lambda: add_one(col("id")), False, add_one_sum, 
2_000_000, 20_000_000
   )
   ns_per_row(
       "shout arrow_udf",
       lambda: shout(col("id").cast("string")),
       True,
       shout_len,
       1_000_000,
       5_000_000,
   )
   ```
   
   </details>
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes, compared with released versions:
   
   - **Batch path:** a `#[spark_wasm_udf]` function with a batch-capable 
signature (above) is now sent as a scalar Arrow UDF (`SQL_SCALAR_ARROW_UDF`) 
and called once per Arrow batch instead of once per row. Results are identical, 
including nulls in and out.
   - **Per-row path kept:** functions with arrays or nested `Option`s, and UDFs 
built by hand with `wasm_udf::udf(...)`, stay per row. To force a batch-capable 
UDF per row, use 
`udf::<fn>_udf().with_eval_type(eval_type::SQL_ARROW_BATCHED_UDF)`.
   - **New API:** `UserDefinedFunction::with_batch_entrypoint(entrypoint)`.
   - **Spark versions:** eval type 250 needs Spark 4.1 or later. The client 
already requires Spark 4.3.0 or later.
   - **Mistyped inputs:** on the batch path, each argument column is converted 
to the declared type with a safe Arrow cast, because Spark passes a column with 
its own type, e.g. a `LongType` column into an `i32` argument.
     - Values that convert exactly (wider integers in range, whole floats) work.
     - An out-of-range integer, or a float with a fractional part into an 
integer argument, now raises `ArrowInvalid`, e.g. `Float value 1.500000 was 
truncated converting to int32`.
     - On the per-row path an out-of-range integer raised `struct.error`, and a 
fractional float was truncated with `int()`.
   - **New error messages on the batch path:**
     - A null in an argument that is not an `Option`: ``WASM UDF 
'spark_batch_udf_<fn>': argument `x` is null in row 1 of the batch, but its 
type is not an Option; declare it as Option<T> to accept nulls``.
     - A module whose batch export takes a different number of columns than the 
UDF declares (only possible with a hand-built `udf(...)`): `WASM UDF 
'<entrypoint>': the batch has 1 columns, but the function takes 2`.
     - Arguments of different lengths: `WASM UDF '<entrypoint>' got arguments 
of different lengths: [3, 2]`.
     - A batch whose input buffers exceed 2 GiB: `WASM UDF '<entrypoint>' got a 
batch of N bytes, more than the 2147483647 a WASM module can take; lower 
spark.sql.execution.arrow.maxRecordsPerBatch`.
     - On the client:
       - `WASM UDF '<name>' has eval type SQL_SCALAR_ARROW_UDF, which needs a 
batch entrypoint; set one with with_batch_entrypoint`.
       - `WASM UDF '<name>' has no arguments, so it cannot be called once per 
batch`.
       - A hand-built `udf(...)` given a batch entrypoint and a type the batch 
path can't carry: `WASM UDF '<name>' has type 'array:i64', so it cannot be 
called once per batch; call it per row with 
with_eval_type(SQL_ARROW_BATCHED_UDF)`.
   
   ### How was this patch tested?
   
   Added tests:
   
   - `tests/wasm-udf` (new; fixture UDFs compiled with the macro and runtime). 
These tests drive the real runners with `pyarrow` arrays, the way Spark's 
worker does:
     - Only batch-capable signatures get the batch runner, and 
`with_eval_type(SQL_ARROW_BATCHED_UDF)` forces the per-row one.
     - For every supported type, the batch runner returns the same values as 
the per-row runner and as the expected values: nulls in and out, two arguments 
of mixed types, a batch of only empty strings, and empty batches. All runners 
of a module share one compiled module.
     - Sliced input (including bitmap bit offsets), chunked input, 
`large_string`/`large_binary`, and wider integer columns are accepted; an 
overflow raises.
     - One runner is called with 2, then 50,000, then 3 rows. The 50,000-row 
batch grows the scratch buffer and the module's memory during the call.
     - Errors name their cause, and the next batch succeeds. Covered: a null in 
a non-`Option` argument, including one whose parameter is `_` and so is named 
by position (`` `#1` ``), both column-count mismatches, arguments of different 
lengths (including an empty one), a batch export the module lacks, a batch over 
2 GiB, a missing `wasmtime` (which raises the install hint), and a panic in 
user code that leaves a `RefCell` borrowed.
     - Every runner test also checks that the Python process exits without 
`Exception ignored` tracebacks on stderr. Without the exit hooks that drop the 
cached wasmtime engine and modules, those objects print these when a process 
that used the batch runner exits normally.
     - The runner tests, including the clean-exit check, also pass with 
wasmtime 17.0.0.
     - The fixture includes functions named `scratch` and `batch_scratch`, so a 
colliding export name fails the build.
   - `tests/wasm-udf/tests/e2e_wasm_udf.rs`: against a live server, covers a 
batch UDF with nulls, a `String` UDF, a two-argument UDF, an array UDF that 
stays per row, and `spark.udf().register(...)` followed by a SQL call.
   - `spark-connect-macros`: unit test of which types the batch path accepts.
   - `wasm_udf.rs`: unit tests that the eval type decides whether the spec 
carries the batch entrypoint, and that eval type 250 is rejected without a 
batch entrypoint, without arguments, or with a type the batch path can't carry 
(an array argument, an `Option` of an array, a nested `Option` return).
   - CI: the `wasm-udf` job also runs the macro unit tests and the runner 
tests. The `connect-e2e` job adds the wasm32 target, `wasmtime` and the 
packer's `pyspark`, and runs the e2e test.
   
   Ran locally (Spark 4.3.0 Connect server, Java 21, Python 3.12 with pyspark 
4.3.0, pyarrow 26.0.0, wasmtime 50.0.1):
   
   ```bash
   cargo test -p apache-spark-connect --features wasm-udf                       
               # 703 passed, 27 ignored
   cargo test -p apache-spark-connect --features wasm-udf -- --ignored 
packer_produces         # 1 passed
   cargo test -p apache-spark-connect-macros                                    
               # 1 passed
   cargo test --manifest-path tests/wasm-udf/Cargo.toml -- --ignored            
               # 4 passed
   SPARK_REMOTE=sc://localhost:15002 cargo test --manifest-path 
tests/wasm-udf/Cargo.toml --test e2e_wasm_udf  # 1 passed
   cargo test -p apache-spark-connect --release --lib                           
               # 461 passed
   cargo test -p apache-spark-connect --release --no-run
   cargo test -p apache-spark-connect --release --features wasm-udf --no-run
   cargo fmt --all --check
   cargo fmt --manifest-path tests/wasm-udf/Cargo.toml --check
   cargo build -p apache-spark-connect-macros -p apache-spark-connect-build
   cargo build --manifest-path examples/wasm-udfs/Cargo.toml
   cargo build --manifest-path examples/wasm-udf-inline/Cargo.toml
   cargo build -p examples --bin wasm_udf_macro --features 
examples/wasm-udf-macro
   python scripts/check_doc_snippets.py
   ```
   
   `cargo clippy --workspace --all-targets` reports the same diagnostics as on 
master, and none in the changed files.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Opus 5.5
   


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