Files
lancedb/docs/web/curation/create-function.mdx
T
Xuanwo 63aa06ef7d feat(python): expose Function GPU classes and placement (#4791)
## Summary

- Add sync/async `DBConnection.list_gpu_classes()` returning typed
`GpuClass` records for `GET /v1/gpu/classes`. Records include the class
name, Kubernetes resource, GPU model and memory, per-worker GPU maximum,
and worker CPU/memory/ephemeral-storage limits.
- Add `udf(gpu_class=...)` for immutable Function-level GPU placement.
It requires `gpu=True`; Function versions expose their registered
`gpu_class`.
- Add `gpu_class` and `gpu_count` to `refresh_column` /
`refresh_column_async` and Rust `RefreshColumnOptions`. Both are sent as
top-level `backfill_column` fields and validated before any request.
`gpu_count` is the aggregate GPU budget across Function workers.
- Validate GPU class names with the server lowercase 63-character
DNS-label rule.
- Document the discovery-to-registration flow, configured hardware
versus free capacity, System Usage permission, and per-refresh GPU
placement in the API and web guides.

Older servers without `/v1/gpu/classes` produce a clear
`NotImplementedError` instead of a bare 404.

Part of ENT-3470. The client follows the in-flight Sophon contract on
`xuanwo/ent-3470-fix` for GPU class discovery and backfill placement
options.

## Breaking change

`PythonRuntimeSpec::PythonV2` adds `gpu_class`, and
`RefreshColumnOptions` adds `gpu_class` and `gpu_count`. Rust callers
constructing either value with all fields explicitly must supply the new
fields.

## Validation

At `7685c968`: Rust GPU tests (5 passed), Rust/PyO3 clippy, Python
extension build, pyright, ruff, and Python Function/runtime, discovery,
refresh placement, and refresh regressions (147 passed, 6 skipped).

At `b22428fd`: Rust GPU tests (5 passed), rustfmt, ruff, pyright,
Function slice 2 (85 passed), and remote GPU/refresh tests (20 passed).

The final commit only updates documentation and Rust doc comments.
2026-11-02 08:50:36 +08:00

230 lines
8.3 KiB
Plaintext

---
title: "Create a Function"
sidebarTitle: "Create Function"
description: "Register Python as a column: CPU, GPU, and remote-service Functions, called once per row or once per batch."
icon: code
keywords: ["function", "udf", "cpu", "gpu", "batch", "scalar", "blob", "remote api", "embedding", "rate limiter"]
availability:
embedded: unavailable
enterprise: available
---
Some curation steps cannot be written as a SQL expression: an embedding
model, an image decode, a call to an external service. A Function is a
Python callable registered with the database, versioned, and then applied as
a column or called in a query -- the code runs where the data is, not pulled
into your process first. This page covers how to shape one; later curation
tasks such as [Chunking](/curation/chunking) assume you have read it.
## Scalar: once per row {#scalar}
The plain form takes one row's values and returns one result. Types are
inferred from the annotations:
<CodeGroup>
```python Python icon="python"
from lancedb import udf
@udf(pip=["numpy==2.2.0"])
def score(value: float) -> float:
return value * 2
version = db.create_function_async(score).wait()
table.add_columns({"doubled": version(value=col("value"))})
```
</CodeGroup>
`pip` declares the worker's dependencies; they are part of the function's
immutable version, so changing one produces a new version rather than
altering rows a previous version already filled.
## Batch: once per call, many rows at a time {#batch}
A function is called once per batch instead of once per row when its
parameters are annotated `pyarrow.Array`, or when it takes a single
`pyarrow.RecordBatch` parameter to read more than one column together. The
same doubling logic, vectorized over a whole batch at once:
<CodeGroup>
```python Python icon="python"
import pyarrow as pa
import pyarrow.compute as pc
from lancedb import udf
@udf(pip=["numpy==2.2.0"])
def score_batch(value: pa.Array) -> pa.Array:
return pc.multiply(value, 2)
```
</CodeGroup>
Batching amortizes per-call overhead across many rows, which matters most
once a function also does something with real fixed cost per call -- loading
a model, opening a connection, making a network round trip.
## Stateful Functions: load once, call many times {#stateful}
A `@udf`-decorated **class** is the same callable with state. Each remote
instance calls `__init__` once, calls `__call__` for every row or batch it
processes, and calls `close()`, if defined, once when it retires:
<CodeGroup>
```python Python icon="python"
from lancedb import udf
@udf
class scale:
def __init__(self, factor: float, offset: float = 0.0):
self.factor = factor
self.offset = offset
def __call__(self, value: float) -> float:
return value * self.factor + self.offset
```
</CodeGroup>
Constructor parameters are **initialization fields**, bound once per binding
rather than read per row -- mixed with per-row columns in the same call:
`version(value=col("value"), factor=2.0)`. Load models, open clients, and
build rate limiters in `__init__`; every batch the instance serves then
reuses them, since an instance's state is shared across its own batches but
never across other instances or workers. This is the pattern GPU inference
and remote-API Functions both build on below.
## GPU: batch inference, loaded once {#gpu}
`db.list_gpu_classes()` lists the GPU classes configured for the deployment.
Each record reports the operator's hardware description and limits; it does
not mean a GPU is currently free. Listing requires the System Usage
permission. Pass a returned class name to `gpu_class` to fix that placement
in the Function version. `gpu=True` remains required:
<CodeGroup>
```python Python icon="python"
import pyarrow as pa
from lancedb import udf
gpu_class = next(
gpu.name for gpu in db.list_gpu_classes() if gpu.gpu_model == "NVIDIA A10G"
)
@udf(
gpu=True,
gpu_class=gpu_class,
pip=["torch==2.12.0", "sentence-transformers==5.7.0"],
)
class embed_text:
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
import torch
from sentence_transformers import SentenceTransformer
device = "cuda" if torch.cuda.is_available() else "cpu"
self.model = SentenceTransformer(model_name, device=device)
def __call__(self, text: pa.Array) -> pa.Array:
texts = [t or "" for t in text.to_pylist()]
embeddings = self.model.encode(texts)
return pa.array(embeddings.tolist(), type=pa.list_(pa.float32(), list_size=384))
version = db.create_function_async(embed_text).wait()
table.add_columns({"embedding": version(text=col("caption"))})
```
</CodeGroup>
Older servers that do not implement `GET /v1/gpu/classes` raise
`NotImplementedError`. Without `gpu_class`, the deployment's default GPU
class is used. The class is immutable with the Function version; set a
per-refresh class or aggregate GPU budget through
[`refresh_column_async`](/feature-engineering/refresh-column#resources).
The model loads once per worker instance, in `__init__`; `__call__` only
encodes the batch it was handed. Registering the same logic as a plain
function instead would reload the model on every single row.
## Calling a remote service {#remote}
A function that calls an external API needs a credential without putting it
in the function's source. A Secret is a database-scoped named credential:
`db.create_secret` stores the value once, and binding it at registration time
puts it in the environment variable the function reads, without ever
returning it from any API or recording it in the function's own definition.
Combine it with the stateful form so the HTTP client -- and any rate limiter
around it -- is built once, not reopened on every row:
<CodeGroup>
```python Python icon="python"
import os
from lancedb import EnvVarSecret, udf
db.create_secret("openai-prod", os.environ["OPENAI_API_KEY"])
@udf
class embed_remote:
def __init__(self, model: str = "text-embedding-3-small"):
import os
from openai import OpenAI
self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
self.model = model
def __call__(self, text: str) -> list[float]:
response = self.client.embeddings.create(input=text, model=self.model)
return response.data[0].embedding
version = db.create_function_async(
embed_remote,
secrets=[EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")],
).wait()
```
</CodeGroup>
A mistyped Secret name is caught at registration; a mistyped environment
variable name only surfaces on the first rows, since nothing records which
variables a function reads. There is no built-in retry or per-row
error-skip policy -- a function that calls a flaky service should handle
timeouts and retries itself, inside `__call__`.
## Blob v2 input and output {#blob}
A Function can bind a [Blob v2 column](/management/table#managed-blobs) and
return another one, including a blob nested inside a struct field, not only
at the top level. In row mode, a blob parameter arrives as Python `bytes`
(or a `BlobReader` for incremental, seekable access, so a function never
has to materialize a payload it only partially needs):
<CodeGroup>
```python Python icon="python"
import pyarrow as pa
import lancedb
from lancedb import BlobReader, udf
@udf(
gpu=True,
pip=["av==13.1.0", "pillow==11.0.0"],
input_schema=pa.schema([
lancedb.blob("video"),
pa.field("ts", pa.float64(), nullable=False),
]),
output_schema=lancedb.blob("jpeg", nullable=False),
)
def extract_frame(video: BlobReader, ts: float) -> bytes:
... # decode the frame at ts, encode as JPEG, return its bytes
```
</CodeGroup>
`output_schema=lancedb.blob(...)` is what makes the result Blob v2 rather
than plain Arrow `binary` -- returning `bytes` with no explicit blob output
schema still holds the right bytes, but skips the Managed storage path and
`fetch_blobs`. Declare it explicitly on every function whose output should
be a blob.
## What to read next {#next}
<CardGroup cols={2}>
<Card title="Chunk records into multiple rows" icon="diagram-subtask" href="/curation/chunking">
A Function whose output is a list, exploded into one row per element.
</Card>
<Card title="Refresh a computed column" icon="arrows-rotate" href="/feature-engineering/refresh-column">
Filling the column a Function is bound to, and re-filling it as inputs change.
</Card>
</CardGroup>