Skip to content

Commit b8ed34c

Browse files
feat(bigframes): Enable local udf execution (#17588)
1 parent f492d3d commit b8ed34c

7 files changed

Lines changed: 236 additions & 67 deletions

File tree

packages/bigframes/bigframes/core/compile/polars/compiler.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@
3737
import bigframes.operations.generic_ops as gen_ops
3838
import bigframes.operations.json_ops as json_ops
3939
import bigframes.operations.numeric_ops as num_ops
40+
import bigframes.operations.remote_function_ops as remote_function_ops
4041
import bigframes.operations.string_ops as string_ops
42+
import bigframes.operations.struct_ops as struct_ops
4143
from bigframes.core import agg_expressions, identifiers, nodes, ordering, window_spec
4244
from bigframes.core.compile.polars import lowering
4345

@@ -122,7 +124,7 @@ def _bigframes_dtype_to_polars_dtype(
122124
]
123125
)
124126
if bigframes.dtypes.is_array_like(dtype):
125-
return pl.Array(
127+
return pl.List(
126128
inner=_bigframes_dtype_to_polars_dtype(
127129
bigframes.dtypes.get_array_inner_type(dtype)
128130
)
@@ -502,6 +504,50 @@ def _(self, op: json_ops.ToJSON, input: pl.Expr) -> pl.Expr:
502504
else:
503505
return input.cast(pl.String())
504506

507+
@compile_op.register(json_ops.ToJSONString)
508+
def _(self, op: json_ops.ToJSONString, input: pl.Expr) -> pl.Expr:
509+
from_type = self._expr_types.get(id(input))
510+
511+
def preprocess_binary(
512+
expr: pl.Expr, dtype: bigframes.dtypes.ExpressionType
513+
) -> pl.Expr:
514+
if dtype == bigframes.dtypes.BYTES_DTYPE:
515+
return expr.bin.encode("base64")
516+
if bigframes.dtypes.is_struct_like(dtype):
517+
fields = bigframes.dtypes.get_struct_fields(dtype)
518+
return pl.struct(
519+
*[
520+
preprocess_binary(
521+
expr.struct.field(name), field_type
522+
).alias(name)
523+
for name, field_type in fields.items()
524+
]
525+
)
526+
if bigframes.dtypes.is_array_like(dtype):
527+
inner_type = bigframes.dtypes.get_array_inner_type(dtype)
528+
return expr.list.eval(preprocess_binary(pl.element(), inner_type))
529+
return expr
530+
531+
preprocessed = preprocess_binary(input, from_type)
532+
533+
if bigframes.dtypes.is_struct_like(from_type):
534+
result = preprocessed.struct.json_encode()
535+
elif from_type == bigframes.dtypes.INT_DTYPE:
536+
result = preprocessed.cast(pl.String)
537+
elif from_type == bigframes.dtypes.BOOL_DTYPE:
538+
result = (
539+
pl.when(preprocessed)
540+
.then(pl.lit("true"))
541+
.otherwise(pl.lit("false"))
542+
)
543+
elif from_type == bigframes.dtypes.BYTES_DTYPE:
544+
result = pl.lit('"') + preprocessed + pl.lit('"')
545+
else:
546+
wrapped = pl.struct(value=preprocessed).struct.json_encode()
547+
result = wrapped.str.slice(9, wrapped.str.len_chars() - 10)
548+
549+
return pl.when(input.is_null()).then(pl.lit("null")).otherwise(result)
550+
505551
@compile_op.register(arr_ops.ToArrayOp)
506552
def _(self, op: ops.ToArrayOp, *inputs: pl.Expr) -> pl.Expr:
507553
return pl.concat_list(*inputs)
@@ -532,6 +578,36 @@ def _(self, op: ops.ArrayReduceOp, input: pl.Expr) -> pl.Expr:
532578
f"Haven't implemented array aggregation: {op.aggregation}"
533579
)
534580

581+
@compile_op.register(struct_ops.StructOp)
582+
def _(self, op: struct_ops.StructOp, *inputs: pl.Expr) -> pl.Expr:
583+
return pl.struct(**{col: inp for col, inp in zip(op.column_names, inputs)}) # type: ignore
584+
585+
@compile_op.register(struct_ops.StructFieldOp)
586+
def _(self, op: struct_ops.StructFieldOp, *inputs: pl.Expr) -> pl.Expr:
587+
return inputs[0].struct[op.name_or_index]
588+
589+
@compile_op.register(remote_function_ops.PythonUdfOp)
590+
def _(self, op: ops.PythonUdfOp, *inputs: pl.Expr) -> pl.Expr:
591+
from bigframes.functions import function_template
592+
593+
code = op.function_def.code.to_callable()
594+
if op.function_def.signature.is_row_processor:
595+
596+
def handler(py_struct):
597+
args = list(py_struct.values())
598+
series_arg = function_template.get_pd_series(args[0])
599+
return code(series_arg, *args[1:])
600+
else:
601+
602+
def handler(py_struct):
603+
return code(*(field for field in py_struct.values()))
604+
605+
return pl.struct(*inputs).map_elements(
606+
handler,
607+
return_dtype=_bigframes_dtype_to_polars_dtype(op.output_type()),
608+
skip_nulls=False,
609+
)
610+
535611
@dataclasses.dataclass(frozen=True)
536612
class PolarsAggregateCompiler:
537613
scalar_compiler = PolarsExpressionCompiler()

packages/bigframes/bigframes/functions/_function_session.py

Lines changed: 5 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,16 @@
1515

1616
from __future__ import annotations
1717

18-
import collections.abc
1918
import functools
20-
import inspect
2119
import logging
2220
import random
2321
import string
24-
import sys
2522
import threading
2623
import time
2724
import warnings
2825
from typing import (
2926
TYPE_CHECKING,
30-
Any,
3127
Literal,
32-
Mapping,
3328
Optional,
3429
Sequence,
3530
Union,
@@ -512,22 +507,10 @@ def wrapper(func):
512507
TypeError, f"func must be a callable, got {func}"
513508
)
514509

515-
if sys.version_info >= (3, 10):
516-
# Add `eval_str = True` so that deferred annotations are turned into their
517-
# corresponding type objects. Need Python 3.10 for eval_str parameter.
518-
# https://docs.python.org/3/library/inspect.html#inspect.signature
519-
signature_kwargs: Mapping[str, Any] = {"eval_str": True}
520-
else:
521-
signature_kwargs = {} # type: ignore
522-
523-
py_sig = _resolve_signature(
524-
inspect.signature(func, **signature_kwargs),
510+
udf_sig = _utils.get_func_signature(
511+
func,
525512
input_types,
526513
output_type,
527-
)
528-
529-
udf_sig = udf_def.UdfSignature.from_py_signature(
530-
py_sig
531514
).to_remote_function_compatible()
532515

533516
full_package_requirements = _utils.get_updated_package_requirements(
@@ -786,23 +769,11 @@ def wrapper(func):
786769
TypeError, f"func must be a callable, got {func}"
787770
)
788771

789-
if sys.version_info >= (3, 10):
790-
# Add `eval_str = True` so that deferred annotations are turned into their
791-
# corresponding type objects. Need Python 3.10 for eval_str parameter.
792-
# https://docs.python.org/3/library/inspect.html#inspect.signature
793-
signature_kwargs: Mapping[str, Any] = {"eval_str": True}
794-
else:
795-
signature_kwargs = {} # type: ignore
796-
797-
py_sig = inspect.signature(
772+
udf_sig = _utils.get_func_signature(
798773
func,
799-
**signature_kwargs,
774+
input_types,
775+
output_type,
800776
)
801-
py_sig = _resolve_signature(py_sig, input_types, output_type)
802-
803-
# The function will actually be receiving a pandas Series, but allow
804-
# both BigQuery DataFrames and pandas object types for compatibility.
805-
udf_sig = udf_def.UdfSignature.from_py_signature(py_sig)
806777

807778
code_def = udf_def.CodeDef.from_func(func, package_requirements=packages)
808779
requirements = udf_def.RuntimeRequirements(
@@ -878,36 +849,6 @@ def deploy_udf(
878849
return self.udf(_force_deploy=True, **kwargs)(func)
879850

880851

881-
def _resolve_signature(
882-
py_sig: inspect.Signature,
883-
input_types: Union[None, type, Sequence[type]] = None,
884-
output_type: Optional[type] = None,
885-
) -> inspect.Signature:
886-
if input_types is not None:
887-
if not isinstance(input_types, collections.abc.Sequence):
888-
input_types = [input_types]
889-
if _utils.has_conflict_input_type(py_sig, input_types):
890-
msg = bfe.format_message(
891-
"Conflicting input types detected, using the one from the decorator."
892-
)
893-
warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning)
894-
py_sig = py_sig.replace(
895-
parameters=[
896-
par.replace(annotation=itype)
897-
for par, itype in zip(py_sig.parameters.values(), input_types)
898-
]
899-
)
900-
if output_type:
901-
if _utils.has_conflict_output_type(py_sig, output_type):
902-
msg = bfe.format_message(
903-
"Conflicting return type detected, using the one from the decorator."
904-
)
905-
warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning)
906-
py_sig = py_sig.replace(return_annotation=output_type)
907-
908-
return py_sig
909-
910-
911852
def get_cloud_function_name(
912853
function_def: udf_def.CloudRunFunctionConfig, session_id=None, uniq_suffix=False
913854
):

packages/bigframes/bigframes/functions/_utils.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
# limitations under the License.
1414

1515

16+
import collections
1617
import hashlib
1718
import inspect
1819
import json
1920
import sys
2021
import typing
2122
import warnings
22-
from typing import Any, Optional, Sequence, Set, cast
23+
from typing import Any, Mapping, Optional, Sequence, Set, cast
2324

2425
import cloudpickle
2526
import google.api_core.exceptions
@@ -31,7 +32,7 @@
3132

3233
import bigframes.exceptions as bfe
3334
import bigframes.formatting_helpers as bf_formatting
34-
from bigframes.functions import function_typing
35+
from bigframes.functions import function_typing, udf_def
3536

3637
# Naming convention for the function artifacts
3738
_BIGFRAMES_FUNCTION_PREFIX = "bigframes"
@@ -304,3 +305,54 @@ def has_conflict_output_type(
304305
return False
305306

306307
return return_annotation != output_type
308+
309+
310+
def get_func_signature(
311+
func,
312+
input_types: type | Sequence[type] | None = None,
313+
output_type: type | None = None,
314+
) -> udf_def.UdfSignature:
315+
if sys.version_info >= (3, 10):
316+
# Add `eval_str = True` so that deferred annotations are turned into their
317+
# corresponding type objects. Need Python 3.10 for eval_str parameter.
318+
# https://docs.python.org/3/library/inspect.html#inspect.signature
319+
signature_kwargs: Mapping[str, Any] = {"eval_str": True}
320+
else:
321+
signature_kwargs = {} # type: ignore
322+
323+
py_sig = resolve_signature(
324+
inspect.signature(func, **signature_kwargs),
325+
input_types,
326+
output_type,
327+
)
328+
return udf_def.UdfSignature.from_py_signature(py_sig)
329+
330+
331+
def resolve_signature(
332+
py_sig: inspect.Signature,
333+
input_types: type | Sequence[type] | None = None,
334+
output_type: type | None = None,
335+
) -> inspect.Signature:
336+
if input_types is not None:
337+
if not isinstance(input_types, collections.abc.Sequence):
338+
input_types = [input_types]
339+
if has_conflict_input_type(py_sig, input_types):
340+
msg = bfe.format_message(
341+
"Conflicting input types detected, using the one from the decorator."
342+
)
343+
warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning)
344+
py_sig = py_sig.replace(
345+
parameters=[
346+
par.replace(annotation=itype)
347+
for par, itype in zip(py_sig.parameters.values(), input_types)
348+
]
349+
)
350+
if output_type:
351+
if has_conflict_output_type(py_sig, output_type):
352+
msg = bfe.format_message(
353+
"Conflicting return type detected, using the one from the decorator."
354+
)
355+
warnings.warn(msg, category=bfe.FunctionConflictTypeHintWarning)
356+
py_sig = py_sig.replace(return_annotation=output_type)
357+
358+
return py_sig

packages/bigframes/bigframes/testing/polars_session.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import bigframes.session.execution_spec
2727
import bigframes.session.executor
2828
import bigframes.session.metrics
29+
from bigframes.functions import _utils, function, udf_def
2930

3031

3132
# Does not support to_sql, dry_run, peek, cached
@@ -111,6 +112,29 @@ def read_pandas(self, pandas_dataframe, write_engine="default"):
111112

112113
return bf_df
113114

115+
def udf(
116+
self,
117+
*,
118+
input_types=None,
119+
output_type=None,
120+
**kwargs,
121+
):
122+
def wrapper(func):
123+
udf_sig = _utils.get_func_signature(
124+
func,
125+
input_types,
126+
output_type,
127+
)
128+
129+
code_def = udf_def.CodeDef.from_func(func)
130+
udf_definition = udf_def.PythonUdf(
131+
signature=udf_sig,
132+
code=code_def,
133+
)
134+
return function.UdfRoutine(func=func, _udf_def=udf_definition)
135+
136+
return wrapper
137+
114138
@property
115139
def bqclient(self):
116140
# prevents logger from trying to call bq upon any errors

packages/bigframes/tests/system/small/engines/test_generic_ops.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,25 @@ def test_engines_astype_to_json(scalars_array_value: array_value.ArrayValue, eng
300300
assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine)
301301

302302

303+
@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True)
304+
def test_engines_to_json_string(scalars_array_value: array_value.ArrayValue, engine):
305+
exprs = [
306+
ops.ToJSONString().as_expr(expression.deref("int64_col")),
307+
ops.ToJSONString().as_expr(
308+
# Use a const since float to json has precision issues
309+
expression.const(5.2, bigframes.dtypes.FLOAT_DTYPE)
310+
),
311+
ops.ToJSONString().as_expr(expression.deref("bool_col")),
312+
ops.ToJSONString().as_expr(
313+
# Use a const since "str_col" has special chars.
314+
expression.const('"hello world"', bigframes.dtypes.STRING_DTYPE)
315+
),
316+
]
317+
arr, _ = scalars_array_value.compute_values(exprs)
318+
319+
assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine)
320+
321+
303322
@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True)
304323
def test_engines_astype_timedelta(scalars_array_value: array_value.ArrayValue, engine):
305324
arr = apply_op(

0 commit comments

Comments
 (0)