GPU Accelerated Offline Features

Rene Ravanan - Software Engineer
by Rene Ravanan
October 28, 2026

At Chalk, the federated query engine is one of our core products. It serves online features at low latency or runs large scale offline queries at high throughput to generate training datasets. We are approaching the limit of CPU performance for our offline queries after already having exploited vectorised instructions and multi-core parallelism. Given that we are always looking for the frontier of efficiency and performance, we started exploring how these workloads would run on a processor better built to handle parallelism.

In this post we dive into some of our insights from using GPUs to accelerate dataset generation with Velox’s cuDF implementation. This acceleration will enable researchers and data scientists to iterate faster when developing their ML models. We began by investigating where GPUs have the most impact with controlled experiments, and then applied these learnings to real production workloads that demonstrated significant performance gains.

How are queries run on a GPU?

Chalk’s Engine relies on a query planner to run queries. Our planner models customers’ feature definitions as a DAG (Directed Acyclic Graph) of relations. When a user submits a query, the planner constructs a logical plan to evaluate the desired output features from given inputs. The next layer of the plan takes this logical plan and lowers it into a physical Velox, an open source query execution engine, plan to define how a query runs on computers.

A query plan in Velox is defined as a tree of relational operators. Each operator represents a transformation on a row vector, such as an aggregation or a sort. Once Chalk has determined which fragments of our physical plan are GPU worthy, the Velox cuDF adapter replaces eligible operators with cuDF operators that perform their transformations on a GPU instead of CPU.

A Velox cuDF Operator follows the same interface as their CPU counterparts while differing in implementation:

  • They apply transformations on device-native (that is, GPU native) cuDF Vectors.
  • Each cuDF Vector also provides a CUDA stream.

A cuDF-backed operator translates its Velox operation into one or more cuDF calls—a filter for example. cuDF schedules the corresponding CUDA kernels on the vector’s stream and returns device-resident output buffers to be consumed by the next cuDF Operator.

Velox query execution on CPU and GPU, showing pull requests, returned batches, and CUDA stream work

Figure 1: Velox query’s execution model on CPU vs GPU

As with most GPU programs, there is a tradeoff between fitting a dataset on device memory while giving hardware enough work to saturate compute. Next, we ran some experiments to see how this tradeoff mapped onto analytical query workloads. All experiments were performed on an NVIDIA RTX PRO 6000 Blackwell GPU.

When does GPU execution pay off?

We ran some TPC-H like queries through our execution engine to build an intuition for when GPU execution is worth its cost. TPC-H is a standardised set of queries often used to benchmark analytical database systems. These benchmarks allow us to make controlled variants in input size, data transformations, and result cardinality. We measured end-to-end execution times on these queries and collected traces using NVIDIA’s Nsight to answer:

  • How to move and maintain data on a GPU during a query?
  • What kind of operations is a GPU best suited for?
  • What kind of operations are worse off on GPU than CPU?

Experiment 1: Moving data to the GPU

We receive input data in the form of Parquet files that need to be decoded before running a query. Velox gives us two options to do this:

  • Host side scan: Decoding the parquet file in host memory and transferring the uncompressed bytes to device memory.
  • Device side scan: Transferring the compressed Parquet file to device memory and performing the decode operation natively.

Intuitively, the device-side scan seems attractive because it will move fewer bytes while also leveraging GPU parallelism for repetitive decoding operations.

We ran a benchmark with 1.75 GiB of Parquet data on TPCH-Q07 comparing the two configurations and measured data transferred, host-to-device (H2D) transfer counts and payload sizes, and query time:

GPU-native scan transferred 1.64 GiB in 36,720 H2D transfers with 49 KB payloads; CPU scan transferred 5.31 GiB in about 7,900 H2D transfers averaging 720 KB. Query time was 10.576 seconds for the GPU-native scan and 9.512 seconds for the CPU scan.

DATA TRANSFERRED

H2D transfers

AVERAGE PAYLOAD

QUERY TIME

GPU-native scanCPU scan

Figure 2: GPU-native and CPU-side Parquet scan traffic and query runtime for TPCH-Q07 (1.75 GiB input, 32 shards).

Unfortunately, reducing the number of bytes transferred alone did not offset the cost of these many H2D transfers. The cost of the overhead of the device-native decode operations outweighed the benefits of a smaller H2D transfer. While GPU-native scanning transferred less data, it issued more H2D transfers with smaller 49KB payloads, versus fewer H2D transfers averaging 720 KB for a CPU scan. Each payload warrants its own set of decompression and decode kernels, and the CUDA kernel launches needed to do that work are expensive.

The question then becomes, how can we reduce the number of transfers while maintaining the same number of bytes transferred? If we reorganize our input data into fewer but larger Parquet shards of the same dataset, we can have fewer decode kernels with each of them having more meaningful work.

We reran the same experiment but with a new layout for input data: 1.75 GiB split into 8 shards. We reduced end-to-end runtime from 9.73s to 6.66s—a 32% improvement. The following graph from Nsight traces validates our hypothesis:

With 32 smaller versus 8 larger Parquet shards, D2H copies fell from 68,470 to 8,206, H2D copies from 36,720 to 4,300, kernel launches from 90,904 to 10,266, and managed allocations from 148,264 to 17,113. Scan/decode SM activity rose from 28.6% to 46.5%.

D2H COPIES

H2D COPIES

KERNEL LAUNCHES

MANAGED ALLOCATIONS

SCAN/DECODE SM ACTIVE

32 smaller shards8 larger shards

Figure 3: TPCH-Q07 scan events and SM activity with 32 smaller versus 8 larger Parquet shards.

The trace confirms that producing fewer but larger pieces of work results in meaningful performance gains. H2D and D2H movements reduced by 88%. The number of kernel launches also fell by 89% while SM util went up by 62%. Unlike a CPU, if we setup our input data into fewer but larger chunks, we get more efficient decode kernels and less runtime overhead.

Experiment 2: Finetuning parallelism

Now that we have our data loaded on device, we can start to reason about how operations on it should be executed.

In Velox, the number of drivers controls the number of concurrent batches that are processed. A Velox driver is a single thread that executes a pipeline of operators. Therefore, one way to control parallelism of query execution is through the number of drivers.

On GPU however, increasing driver count may not always improve throughput of a query. A single driver’s kernel launch could make use of all the streaming multiprocessors on the device! Conversely, additional drivers with multiple concurrent pipelines can lead to contention for device memory and other compute resources. Hence, one must find a balance between compute saturation and hardware resource contention when scheduling work on GPUs. Sweeping across driver counts TPCH-Q07 show us the following:

TPCH-Q07 median runtime was 18.29 seconds with 1 driver, 18.10 seconds with 2, and 35.17 seconds with 4. Standard deviations were 0.065, 0.147, and 8.97 seconds.

RUNTIME (S)

Figure 4: TPCH-Q07 median runtime by GPU driver count.

Moving from one to two drivers produced no material runtime improvement, while four drivers increased runtime nearly twofold and introduced substantial variability. The traces show that four drivers introduced significantly more memory operations.

At 2 versus 4 drivers, unified H2D copies were 677,936 versus 804,748, D2H copies were 22,057 versus 295,241, and observed H2D copy time was 1.61 versus 9.37 seconds.

H2D COPIES

D2H COPIES

OBSERVED H2D COPY TIME

2 drivers4 drivers

Figure 5: Unified-memory H2D and D2H copy counts and observed H2D copy time for 2 versus 4 drivers.

Unified-memory host-to-device migrations increased from and device-to-host migrations rose more than thirteenfold. The runtime was spending more time maintaining data residency across concurrent producers, rather than executing the query.

This shows that the right driver count is dependent on the shape of the query and that we should strive towards finding the smallest value that hides scan and scheduling gaps while avoiding memory-residency contention.

Experiment 3: The cost of UVM

NVIDIA’s Unified Virtual Memory (UVM) model lets CPU and GPU programs refer to managed allocations through a single virtual address space. A buffer can remain addressable by both sides even when its pages reside only in host memory or only in device VRAM. When a GPU operator accesses a host-resident page, CUDA can migrate it to the device. This capability seemed promising for a hybrid engine like Chalk’s. CPU and GPU stages can pass logical buffers without requiring application code to manage explicit ownership handoff, and queries can address working sets larger than VRAM.

However, the driver-count experiments showed that extra concurrency can cause memory churn rather than throughput when using UVM. To understand why, we looked at how memory is managed across CPU and GPU execution with different allocation strategies. We started with queries whose working datasets fit on device memory. For TPCH-Q06, which has a smaller memory footprint than our TPCH-Q07 run in the previous sections since it does not include a hash join, we compared a device native allocation strategy (async) against using UVM apis for managing device memory (prefetched-managed):

TPCH-Q06 query runtime was 915.3 milliseconds for async allocation and 1,938.6 milliseconds for prefetched managed memory, a 2.12 times difference.

REPORTED QUERY RUNTIME

asyncprefetch_managed

Figure 6: Reported TPCH-Q06 runtimes for async and prefetched managed-memory allocation; sample points are not shown because their underlying values were not reported.

Using the device native allocation strategy was 2.12x faster. Since neither configuration required oversubscription of device memory, there is likely significant overhead of maintaining migration-capable allocations. Traces for the same confirm our hypothesis:

TPCH-Q06 CPU memory APIs, GPU memory operations, and GPU kernels over time for async and prefetched-managed allocation, constructed from Nsight traces.

async

Last GPU event: 3.27 s

prefetch_managed

Last GPU event: 6.15 s
CPU memory APIGPU memory operationGPU kernelCPU active, no GPU work recorded

Figure 7: TPCH-Q06 CPU and GPU activity timeline constructed from Nsight traces.

While we can't directly compare these runtimes with those from Figure 6 due to the overhead from Nsight tracing, we can see that for the UVM based allocation configuration (prefetched managed), the GPU idles more compared to the device native allocation (async) config.

This cost compounds when a higher concurrency expands the active working set of a query beyond device memory. Re-visiting the TPCH-Q07 workload from the previous section, examining more memory traces justifies our reasoning:

At 2 versus 4 drivers, TPCH-Q07 runtime was 18.10 versus 35.17 seconds, UVM transfer volume was about 80.1 GiB versus 978 GiB, and cudaMemPrefetchAsync time was 12.4 versus 307.8 seconds.

RUNTIME (S)

TRANSFER VOLUME (GiB)

CUMULATIVE PREFETCH API-CALL TIME ACROSS THE FULL TRACE (S)

2 drivers4 drivers

Figure 8: TPCH-Q07 runtime, UVM transfer volume, and cudaMemPrefetchAsync API time for 2 versus 4 drivers.

UVM transfer volume increased more than tenfold along with the cumulative time spent in the `cudaMemPrefetchAsync` API time across all threads. This is the main tradeoff that shaped the rest of our tuning work: it is critical to maintain the active working set across all drivers in device memory to avoid expensive repeated page fault thrash.

Why joins make this harder

Joins keep their build-side hash table in GPU memory throughout the probe phase. Unlike scan buffers, which can be released after use, the hash table’s buckets, keys, payloads, and supporting buffers must remain available. Adding more drivers means more probe buffers and more hash-table pages accessed at the same time. When this combined memory footprint exceeds available VRAM, UVM must repeatedly move pages in and out of GPU memory. This creates more memory traffic without increasing useful throughput.

The practical implication is that join-heavy GPU queries need concurrency to be tuned against their resident hash-build footprint. They cannot use the same driver count as other operators simply because the GPU has available compute capacity.

Production Workloads

Our experiments established the operating conditions for effective GPU execution: feed the device sufficiently large units of work to offset runtime overhead and that we must tune concurrency to the query’s memory footprint. Let’s see if these learnings proved to be useful by testing on some real world use cases:

  • A high cardinality aggregation representing behavior patterns for fraud detection.
  • A compute intensive project and TopN reduction for search engine result ranking.

Ordered Aggregations for Fraud-Detection

This workload computes hourly behavioral features for fraud detection. For every user and hour, it distinguishes buyer vs. seller activity and calculates metrics like transaction counts, gross volume, type breakdowns, and activity timestamps. These feed fraud signals that track changes in user behavior and marketplace participation, making it a valuable dataset to train a model for fraud detection.

This workload groups user activity into millions of groups (one per user-hour pair) and tracks multiple aggregate states within each. High-cardinality grouping and complex state management are expensive on CPU but naturally parallelizable on GPU. The query executes as:

When we first ran this on the GPU, it did not scale as well as CPU execution with increasing row counts:

For 20 million, 100 million, 500 million, and 4 billion input rows, CPU execution runtimes were 1.195, 3.731, 16.172, and 165.021 seconds. Original GPU execution runtimes were 1.080, 1.420, 2.863, and 290.051 seconds.

RUNTIME (S)

Figure 9: Fraud aggregation runtime for CPU and the original GPU plan across input sizes.

Profiling the 4B input run revealed a new bottleneck: the intermediate state from the partial aggregation was responsible for several D2D copies that accounted for a large portion of the GPU's runtime!

During the original 4-billion-row run with 2 GPU drivers, device-to-device memcpy accounted for 42.3% of summed GPU activity duration; grouped aggregation output kernels, 25.6%; Zstd decompression, 17.1%; host-to-device and device-to-host memcpy, 0.2%; CUDA memset, 0.1%; and other GPU kernels, 14.7%.

Device-to-device memcpy

110.8 s · 116,882 copies

Grouped aggregation output kernel

67.052 s · 2,661 instances

Zstd decompression kernel

44.771 s · 922 instances

Host↔device memcpy

0.519 s · 270,589 instances

CUDA memset

0.153 s · 36,249 instances

Other GPU kernels

38.606 s · 277,969 instances
Percent of summed GPU activity duration.

Figure 10: GPU activity shares (percent of summed event duration), accumulated time, and copy counts during the original 4B fraud aggregation run with 2 GPU drivers.

Partial aggregation is flushed frequently, producing wide intermediate rows that had to be materialized, repartitioned, and merged—an expensive operation for the GPU. We increased `max_partial_aggregation_memory` to retain more local state before flushing, reducing intermediate data volume at the cost of higher peak memory usage.

At partial-state limits of 16 MiB, 4 GiB, 6 GiB, and 7 GiB, runtime was 290.1, 81.8, 53.2, and 124.3 seconds; partial rows were 2.867, 1.892, 1.728, and 1.653 billion; peak memory was 63.9, 74.4, 84.2, and 90.2 GiB. The 8 GiB run ran out of memory.

RUNTIME (S)

PARTIAL ROWS EMITTED (B)

PEAK GPU MEMORY (GiB)

Figure 11: Fraud aggregation runtime, partial rows emitted, and peak GPU memory by per-driver partial-state limit; the 8 GiB run ran out of memory.

Increasing partial aggregation limit from 16 MiB to 6 GiB reduced our runtime from 290.1s to 53.2s. Profiling confirms our hypothesis: The partial aggregation step emits far fewer rows and we spend significantly less time making device-to-device copies.

Using comparison values supplied from the original draft, the 16 MiB and 6 GiB partial-state limits have 116,882 and 10,644 device-to-device copies, with 110.8 and 11.1 seconds of cumulative device-to-device copy time.

D2D COPIES

CUMULATIVE D2D COPY TIME

16 MiB partial-state limit6 GiB partial-state limit

Figure 12: D2D copy count and cumulative copy time at 16 MiB and 6 GiB partial-state limits, using comparison values supplied from the original draft. Copy time is cumulative across GPU events, which can overlap across streams; it is not query wall-clock runtime.

Beyond 6 GiB, memory pressure outweighed savings (7 GiB) or caused OOM (8 GiB), establishing this as the operating point for another end-to-end measurement.

CPU execution runtimes for 20 million, 100 million, 500 million, and 4 billion rows were 1.195, 3.731, 16.172, and 165.021 seconds. With a 6 GiB partial-state limit, GPU execution runtimes were 572.2 milliseconds, 898.9 milliseconds, 2.061 seconds, and 53.193 seconds. Partial output rows fell from 9.66 to 7.14 million, 41.01 to 13.12 million, 149.35 to 13.44 million, and 2.867 to 1.728 billion.

RUNTIME (S)

PARTIAL OUTPUT ROWS · MILLIONS

Figure 13: Fraud aggregation runtime after tuning to a 6 GiB partial-state limit, with partial output rows compared against the default.

The new results validate our experiment, we see an almost 8x speedup and 500M inputs and 3.1x speedup for 4B inputs compared to 0.57x from earlier.

Projected Top-N for Search Engine Results Ranking

This workload ranks search candidates using relevance, personalization, conversion, and quality signals, producing a top-50 dataset that trains a downstream ranking model. The reranking projection performs roughly 390 scalar arithmetic operations per candidate to score them, before selecting the Top-50 listings, providing substantial independent computation across input rows that can benefit from GPU parallelism. The massive reduction to 50 output rows keeps memory usage and data movement minimal.

For 10 million, 100 million, 500 million, and 1 billion rows, CPU execution runtimes were 0.8321, 7.926, 39.137, and 78.069 seconds. GPU execution runtimes were 215.9 milliseconds, 860.7 milliseconds, 3.570 seconds, and 6.962 seconds, giving speedups of 3.85, 9.21, 10.96, and 11.21 times over CPU execution.

RUNTIME (S)

SPEEDUP

Figure 14: Projected Top-N search-ranking runtime and GPU speedup over CPU execution.

At 1B candidate listings (49.4 GiB of Parquet input), GPU execution completed in 6.96 seconds, an 11.2× speedup when compared with CPU execution!

Conclusion

GPUs can dramatically speed up queries, but it is not always a magic bullet. Compute-heavy workloads with large aggregations, projections, and TopN operations can run more than 10x faster. But inefficient data layouts or excessive data movement can erase those gains. We’re continuing to work on GPU-accelerated queries at Chalk. Stay tuned for more.

Want to stay up-to-date with Chalk?

Subscribe for updates on what we’re building (and shipping!) at Chalk