vibeSpatial Integration¶
vibeProj is designed as the GPU projection engine for vibeSpatial. This guide covers the zero-copy integration pattern for projecting coordinate buffers that already live on the GPU.
The problem¶
A typical GIS reprojection workflow copies data off the GPU, reprojects on the CPU via pyproj, then copies back:
GPU buffer → host copy → pyproj (CPU) → host copy → GPU buffer
For a 10M-point GeoDataFrame this adds ~40ms of PCIe transfer on top of ~500ms of CPU projection time. vibeProj eliminates both:
GPU buffer → fused stage kernel(s) (GPU) → GPU buffer
There is one fused launch per mathematical stage and no host round-trip. Projection-only transforms need no intermediate buffers. Multi-stage Helmert/SVD transforms lazily allocate bounded device scratch and reuse it on warmed calls.
transform_buffers() API¶
The key integration point is transform_buffers(). Unlike transform(),
it:
Skips scalar detection and dtype inference
Accepts pre-allocated final output arrays (projection-only calls need no scratch; multi-stage scratch is allocation-free after warm-up)
Returns the same output array objects (verifiable with
is)
import cupy as cp
from vibeproj import Transformer
t = Transformer.from_crs("EPSG:4326", "EPSG:32631")
# Input: device-resident fp64 arrays
x_in = cp.asarray(lat_data, dtype=cp.float64)
y_in = cp.asarray(lon_data, dtype=cp.float64)
# Output: pre-allocated on device
x_out = cp.empty_like(x_in)
y_out = cp.empty_like(y_in)
# Projection-only example: one fused launch, no scratch allocation or host transfer
rx, ry = t.transform_buffers(x_in, y_in, out_x=x_out, out_y=y_out)
assert rx is x_out # same object — no copy
# 3D: ellipsoidal height is transformed through Helmert for cross-datum,
# or passed through unchanged for same-datum transforms
z_in = cp.asarray(height_data, dtype=cp.float64)
z_out = cp.empty_like(z_in)
rx, ry, rz = t.transform_buffers(x_in, y_in, z_in, out_x=x_out, out_y=y_out, out_z=z_out)
Integration with vibeSpatial’s coordinate buffers¶
vibeSpatial stores geometry coordinates as separated CuPy float64 arrays
inside OwnedGeometryArray
(see GPU acceleration and
API reference).
A typical to_crs() implementation looks like:
def to_crs(gdf, target_crs):
"""Reproject a GPU GeoDataFrame using vibeProj."""
from vibeproj import Transformer
src_crs = gdf.crs
t = Transformer.from_crs(src_crs, target_crs)
# Access the raw coordinate buffers (already on GPU)
coords = gdf.geometry._data # OwnedGeometryArray
x_in = coords.x # cupy.ndarray, fp64
y_in = coords.y # cupy.ndarray, fp64
# Pre-allocate output buffers for the new geometry
x_out = cp.empty_like(x_in)
y_out = cp.empty_like(y_in)
# Projection-only example — one fused stage launch
t.transform_buffers(x_in, y_in, out_x=x_out, out_y=y_out)
# Build new GeoDataFrame with projected coordinates
return gdf._with_coordinates(x_out, y_out, crs=target_crs)
The critical property: no data leaves the GPU. Projection-only paths read the input arrays and write final outputs in one kernel. Helmert/SVD paths run two or three fused stage kernels through bounded reusable scratch. Neither path creates intermediate Python coordinate objects or performs host-device round-trips.
Caching the Transformer¶
Transformer.from_crs() resolves CRS metadata via pyproj and
precomputes projection constants. For repeated transforms (e.g. in a
processing loop), create the transformer once and reuse it:
# Create once
t = Transformer.from_crs("EPSG:4326", "EPSG:32631")
# Reuse across many batches
for batch in data_stream:
t.transform_buffers(batch.x, batch.y, out_x=out_x, out_y=out_y)
The first call to transform_buffers() compiles the NVRTC kernel
(~100ms). Subsequent calls reuse the cached compiled kernel. CuPy also
persists the compiled PTX to disk, so the next Python session starts
fast.
Inverse transforms¶
transform_buffers() supports inverse transforms with the same
zero-copy semantics:
# Project: geographic → UTM
t.transform_buffers(lon, lat, out_x=easting, out_y=northing)
# Unproject: UTM → geographic
t.transform_buffers(
easting, northing,
direction="INVERSE",
out_x=lon_out, out_y=lat_out,
)
Chained projection (projected → projected)¶
When both source and destination are projected CRS (e.g. UTM → Web Mercator), vibeProj chains two fused kernel calls through a geographic intermediate. The intermediate coordinates stay on the GPU in registers — no extra device memory is allocated:
t = Transformer.from_crs("EPSG:32631", "EPSG:3857")
t.transform_buffers(utm_x, utm_y, out_x=webmerc_x, out_y=webmerc_y)
Performance expectations¶
On an RTX 4090 with 1M fp64 coordinates:
Operation |
Time |
|---|---|
Fused TM forward kernel |
0.49 ms |
|
< 0.01 ms |
Equivalent pyproj CPU (i9-13900k) |
~90 ms |
Host→Device copy (16 MB) |
~2 ms |
The GPU kernel is 183x faster than CPU pyproj, and transform_buffers()
adds negligible Python overhead on top of the raw kernel time.