Skip to content

Commit a8eee76

Browse files
feat(rust): implement batch merging for CloudFetch results (#381)
## Summary Implement opt-in batch merging in the CloudFetch download pipeline. Small Arrow batches (~556 rows each, ~22 per chunk) are concatenated into larger batches before being served to consumers, reducing per-batch overhead for ODBC and other clients processing large result sets. - New config option: `databricks.cloudfetch.batch_merge_target_rows` (default: 0 = disabled) - Merging happens async in the download task (overlapped with network I/O) - Uses `arrow::compute::concat_batches` for contiguous column buffers - Includes design doc at `rust/docs/designs/odbc-batch-optimization.md` ### Motivation Profiling a Tableau Server workload (51M rows, 89 columns, ~85GB) showed 102s of pure per-batch overhead across 91,952 small batches: | Per-batch cost | Avg | Total (91,952 batches) | |---|---|---| | FFI export (get_next) | 0.30ms | 28s | | Consumer batch import | 0.25ms | 23s | | Previous batch release | 0.11ms | 10s | | Consumer accessor setup | 0.60ms | 55s | With `batch_merge_target_rows=20000`, batch count drops from 91,952 to ~3,929 (one merged batch per CloudFetch chunk), eliminating ~95s of overhead. ### Production Results Tested on the same Tableau workload with the ODBC driver setting `batch_merge_target_rows=20000`: | Metric | Without merging | With merging | |---|---|---| | Batches | 91,952 | 3,929 | | Avg rows/batch | 556 | ~12,000 | | Move(800) avg | 18.0ms | 3.2ms | | Total extract | ~38 min | ~32 min | The 3.2ms Move time includes the benefit of the ODBC prefetch thread (separate PR), but batch merging contributes ~95s of overhead reduction and improved cache locality. ## Test plan - [ ] Unit tests for `merge_batches_to_target`: disabled (target=0), normal merge, end-of-stream remainder, single batch exceeding target, empty batches - [ ] Integration test: verify row counts match with and without merging - [ ] ODBC replay benchmark with `--bind --fetch-size 800` produces correct results - [ ] Production Tableau Server extract completes successfully with `batch_merge_target_rows=20000` This pull request was AI-assisted by Isaac.
1 parent 164a474 commit a8eee76

4 files changed

Lines changed: 476 additions & 0 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<!--
2+
Copyright (c) 2025 ADBC Drivers Contributors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
-->
16+
17+
# Batch Merging for Large Result Sets
18+
19+
## Overview
20+
21+
This document describes an opt-in optimization in the Rust ADBC driver that
22+
merges small Arrow RecordBatches into larger ones before serving them via the
23+
Arrow C Data Interface (`ArrowArrayStream::get_next`). This is a generic
24+
performance optimization that benefits any consumer processing large result
25+
sets, particularly the ODBC driver used with Tableau.
26+
27+
## Motivation
28+
29+
CloudFetch downloads produce Arrow IPC chunks that are split by the server.
30+
Each chunk typically contains batches of ~500-600 rows. For consumers that
31+
process data in larger units (e.g., Tableau fetches 800 rows per `SQLFetch`),
32+
small batches cause significant per-batch overhead:
33+
34+
**Profiling data** (51M rows, 89 columns, Tableau extract):
35+
36+
| Per-batch cost | Avg time | Total (91,952 batches) |
37+
|----------------|----------|------------------------|
38+
| `get_next` (FFI export) | 0.30ms | 28s |
39+
| Consumer batch import | 0.25ms | 23s |
40+
| Previous batch release (FFI callbacks) | 0.11ms | 10s |
41+
| Consumer accessor setup | 0.60ms | 55s |
42+
| **Total per-batch overhead** | **~1.1ms** | **~102s (1.7 min)** |
43+
44+
With 91,952 small batches, per-batch overhead alone accounts for 102 seconds.
45+
Merging into ~8000-row batches reduces the batch count to ~6,400, cutting this
46+
overhead by 93%.
47+
48+
Additionally, larger batches improve cache locality — the consumer reads each
49+
column's data buffer sequentially over more rows before moving to the next
50+
column, reducing CPU cache thrashing.
51+
52+
## Configuration
53+
54+
### `databricks.cloudfetch.batch_merge_target_rows`
55+
56+
- **Type:** String (parsed as `usize`)
57+
- **Default:** `"0"` (disabled — batches pass through unchanged)
58+
- **Recommended for ODBC:** `"8000"`
59+
- **Behavior:** When > 0, the driver accumulates consecutive batches and
60+
concatenates them into larger batches of approximately this many rows before
61+
serving via `get_next`. The last batch in the stream may be smaller.
62+
63+
Set via `DatabaseSetOption`:
64+
65+
```cpp
66+
// C++ (ODBC layer sets this during database initialization)
67+
set_db_option("databricks.cloudfetch.batch_merge_target_rows", "8000");
68+
```
69+
70+
```python
71+
# Python (if desired, though not typically needed)
72+
db.set_option("databricks.cloudfetch.batch_merge_target_rows", "8000")
73+
```
74+
75+
## Implementation
76+
77+
### Config storage
78+
79+
Add to `CloudFetchConfig` in `src/types/cloudfetch.rs`:
80+
81+
```rust
82+
pub struct CloudFetchConfig {
83+
// ... existing fields ...
84+
85+
/// Target number of rows per merged batch. 0 = disabled (pass through as-is).
86+
/// When > 0, consecutive small batches are concatenated into larger batches
87+
/// of approximately this many rows before being served to consumers.
88+
pub batch_merge_target_rows: usize,
89+
}
90+
```
91+
92+
Default: `0`.
93+
94+
### Option parsing
95+
96+
Add to `Database::set_option` in `src/database.rs`, alongside existing
97+
CloudFetch options:
98+
99+
```rust
100+
"databricks.cloudfetch.batch_merge_target_rows" => {
101+
if let OptionValue::String(v) = value {
102+
self.cloudfetch_config.batch_merge_target_rows =
103+
v.parse().map_err(|_| /* invalid option error */)?;
104+
Ok(())
105+
} else {
106+
Err(DatabricksErrorHelper::set_invalid_option(&key, &value).to_adbc())
107+
}
108+
}
109+
```
110+
111+
### Batch merging in the download pipeline
112+
113+
Merging happens in the background download tasks inside
114+
`StreamingCloudFetchProvider::schedule_downloads()` in
115+
`src/reader/cloudfetch/streaming_provider.rs`. After a chunk is downloaded and
116+
parsed into `Vec<RecordBatch>`, the batches are merged before being stored in
117+
the chunks map.
118+
119+
This design means the merge cost is hidden behind download latency — the
120+
consumer always receives pre-merged batches with no additional blocking.
121+
122+
Two helper functions handle the merge:
123+
124+
```rust
125+
/// Merge batches into larger batches up to `target_rows` each.
126+
fn merge_batches_to_target(
127+
batches: Vec<RecordBatch>,
128+
target_rows: usize,
129+
) -> Result<Vec<RecordBatch>> {
130+
// Accumulates consecutive batches until target_rows is reached,
131+
// then flushes (concat_batches) and starts a new accumulator.
132+
// Last batch may be smaller than target. Single batches that
133+
// exceed the target are passed through as-is.
134+
}
135+
136+
/// Concatenate pending batches into a single batch.
137+
fn flush_pending(schema: &SchemaRef, pending: &mut Vec<RecordBatch>) -> Result<RecordBatch>
138+
```
139+
140+
In the download task:
141+
142+
```rust
143+
self.runtime_handle.spawn(async move {
144+
let result = Self::download_chunk_with_retry(...).await;
145+
match result {
146+
Ok(batches) => {
147+
// Merge in the download task — async, parallel with consumer
148+
let batches = if batch_merge_target_rows > 0 {
149+
merge_batches_to_target(batches, batch_merge_target_rows)?
150+
} else {
151+
batches
152+
};
153+
entry.batches = Some(batches);
154+
entry.state = ChunkState::Downloaded;
155+
}
156+
...
157+
}
158+
});
159+
```
160+
161+
The existing `next_batch()` is unchanged — it simply drains `current_batch_buffer`
162+
as before. Since each chunk's batches are already merged, the buffer contains
163+
fewer, larger batches.
164+
165+
### Key considerations
166+
167+
**`concat_batches` copies data.** This is intentional and beneficial:
168+
- Produces a single contiguous buffer per column (better cache locality)
169+
- The sequential copy acts as a cache-warming scan
170+
- Cost is proportional to data size: ~12 MB for 8000 rows of 89 columns,
171+
taking ~1-2ms — negligible vs the 102s overhead it eliminates
172+
173+
**Async merging.** Because merging runs in the download task (a tokio spawn),
174+
it overlaps with downloading of other chunks and with the consumer processing
175+
previous batches. The merge latency is fully hidden.
176+
177+
**Row-target configurability.** The `batch_merge_target_rows` setting controls
178+
the maximum rows per merged batch, not "merge all batches in a chunk". If a
179+
chunk has 24K rows across 44 batches and the target is 20K, it produces two
180+
batches (one ~20K, one ~4K). This keeps the optimization general-purpose
181+
rather than tied to a specific workload's chunk sizes.
182+
183+
**Memory management.** After merging, the source batches are dropped (copied
184+
into the merged batch). The `chunks_in_memory` counter is decremented per
185+
consumed chunk as usual — merging doesn't affect the memory accounting.
186+
187+
**Last batch.** The final merged batch within a chunk may have fewer than
188+
`batch_merge_target_rows` rows. This is expected and correct.
189+
190+
**Inline provider.** The inline result provider (for small result sets returned
191+
directly in the API response) is unaffected — its batches are typically already
192+
consolidated and don't go through CloudFetch's download pipeline.
193+
194+
## Expected Impact
195+
196+
| Batch size | Batches (51M rows) | Batch overhead | Savings |
197+
|------------|-------------------|----------------|---------|
198+
| 556 (current) | 91,952 | 102s ||
199+
| 4000 | 12,774 | 14s | 88s |
200+
| **8000 (recommended)** | **6,387** | **7s** | **95s** |
201+
| 16000 | 3,194 | 4s | 98s |
202+
203+
Diminishing returns above 8000. The recommended value of 8000 captures 93% of
204+
the possible overhead reduction while keeping per-batch memory at ~12 MB.
205+
206+
Additional cache locality improvement is harder to quantify but was measured at
207+
5-10% of data conversion time in benchmarks, corresponding to ~50-100s on the
208+
production workload.
209+
210+
**Total estimated savings: ~150-200s (2.5-3.3 min).**
211+
212+
## Testing
213+
214+
### Unit tests
215+
216+
1. **Disabled (default):** `next_batch()` with `batch_merge_target_rows=0`
217+
returns individual batches unchanged. Verify batch count and row counts
218+
match input.
219+
220+
2. **Merging enabled:** With `batch_merge_target_rows=100` and input batches
221+
of 30 rows each, verify:
222+
- First 3 calls return batches of ~100 rows (3-4 source batches merged)
223+
- Data values are preserved and in correct order
224+
- Schema is preserved
225+
226+
3. **End of stream:** With 250 total rows and `target=100`, verify:
227+
- First 2 calls return ~100-row batches
228+
- Third call returns ~50-row batch (remainder)
229+
- Fourth call returns `None`
230+
231+
4. **Single batch exceeds target:** A source batch with 200 rows and
232+
`target=100` is returned as-is (no splitting).
233+
234+
5. **Empty batches:** Source stream with 0-row batches interspersed — verify
235+
they are skipped during merging.
236+
237+
### Integration test
238+
239+
Verify end-to-end with the ODBC replay benchmark:
240+
241+
```bash
242+
# Should produce identical row counts and data as without merging
243+
./build/Release/tableau_replay_benchmark ~/odbc-wbd 3 --bind --fetch-size 800
244+
```
245+
246+
## File Changes Summary
247+
248+
| File | Change |
249+
|------|--------|
250+
| `src/types/cloudfetch.rs` | Add `batch_merge_target_rows: usize` to `CloudFetchConfig`, default `0` |
251+
| `src/database.rs` | Parse `databricks.cloudfetch.batch_merge_target_rows` in `set_option` |
252+
| `src/reader/cloudfetch/streaming_provider.rs` | Add `merge_batches_to_target()` + `flush_pending()`, merge in download task |

rust/src/database.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,19 @@ impl Optionable for Database {
273273
Err(DatabricksErrorHelper::set_invalid_option(&key, &value).to_adbc())
274274
}
275275
}
276+
"databricks.cloudfetch.batch_merge_target_rows" => {
277+
if let Some(v) = Self::parse_int_option(&value) {
278+
if v < 0 {
279+
return Err(
280+
DatabricksErrorHelper::set_invalid_option(&key, &value).to_adbc()
281+
);
282+
}
283+
self.cloudfetch_config.batch_merge_target_rows = v as usize;
284+
Ok(())
285+
} else {
286+
Err(DatabricksErrorHelper::set_invalid_option(&key, &value).to_adbc())
287+
}
288+
}
276289

277290
// Logging options
278291
"databricks.log_level" => {
@@ -595,6 +608,9 @@ impl Optionable for Database {
595608
"databricks.cloudfetch.max_retries" => {
596609
Ok(self.cloudfetch_config.max_retries as i64)
597610
}
611+
"databricks.cloudfetch.batch_merge_target_rows" => {
612+
Ok(self.cloudfetch_config.batch_merge_target_rows as i64)
613+
}
598614
"databricks.auth.redirect_port" => self
599615
.auth_config
600616
.redirect_port

0 commit comments

Comments
 (0)