datafusion_common/utils/memory.rs
1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! This module provides a function to estimate the memory size of a HashTable prior to allocation
19
20use crate::error::_exec_datafusion_err;
21use crate::{HashSet, Result};
22use arrow::array::ArrayData;
23use arrow::record_batch::RecordBatch;
24use std::mem::size_of;
25use std::num::NonZero;
26
27/// Estimates the memory size required for a hash table prior to allocation.
28///
29/// # Parameters
30/// - `num_elements`: The number of elements expected in the hash table.
31/// - `fixed_size`: A fixed overhead size associated with the collection
32/// (e.g., HashSet or HashTable).
33/// - `T`: The type of elements stored in the hash table.
34///
35/// # Details
36/// This function calculates the estimated memory size by considering:
37/// - An overestimation of buckets to keep approximately 1/8 of them empty.
38/// - The total memory size is computed as:
39/// - The size of each entry (`T`) multiplied by the estimated number of
40/// buckets.
41/// - One byte overhead for each bucket.
42/// - The fixed size overhead of the collection.
43/// - If the estimation overflows, we return a [`crate::error::DataFusionError`]
44///
45/// # Examples
46/// ---
47///
48/// ## From within a struct
49///
50/// ```rust
51/// # use datafusion_common::utils::memory::estimate_memory_size;
52/// # use datafusion_common::Result;
53///
54/// struct MyStruct<T> {
55/// values: Vec<T>,
56/// other_data: usize,
57/// }
58///
59/// impl<T> MyStruct<T> {
60/// fn size(&self) -> Result<usize> {
61/// let num_elements = self.values.len();
62/// let fixed_size =
63/// std::mem::size_of_val(self) + std::mem::size_of_val(&self.values);
64///
65/// estimate_memory_size::<T>(num_elements, fixed_size)
66/// }
67/// }
68/// ```
69/// ---
70/// ## With a simple collection
71///
72/// ```rust
73/// # use datafusion_common::utils::memory::estimate_memory_size;
74/// # use std::collections::HashMap;
75///
76/// let num_rows = 100;
77/// let fixed_size = std::mem::size_of::<HashMap<u64, u64>>();
78/// let estimated_hashtable_size =
79/// estimate_memory_size::<(u64, u64)>(num_rows, fixed_size)
80/// .expect("Size estimation failed");
81/// ```
82pub fn estimate_memory_size<T>(num_elements: usize, fixed_size: usize) -> Result<usize> {
83 // For the majority of cases hashbrown overestimates the bucket quantity
84 // to keep ~1/8 of them empty. We take this factor into account by
85 // multiplying the number of elements with a fixed ratio of 8/7 (~1.14).
86 // This formula leads to over-allocation for small tables (< 8 elements)
87 // but should be fine overall.
88 num_elements
89 .checked_mul(8)
90 .and_then(|overestimate| {
91 let estimated_buckets = (overestimate / 7).next_power_of_two();
92 // + size of entry * number of buckets
93 // + 1 byte for each bucket
94 // + fixed size of collection (HashSet/HashTable)
95 size_of::<T>()
96 .checked_mul(estimated_buckets)?
97 .checked_add(estimated_buckets)?
98 .checked_add(fixed_size)
99 })
100 .ok_or_else(|| {
101 _exec_datafusion_err!("usize overflow while estimating the number of buckets")
102 })
103}
104
105/// Calculate total used memory of this batch.
106///
107/// This function is used to estimate the physical memory usage of the `RecordBatch`.
108/// It only counts the memory of large data `Buffer`s, and ignores metadata like
109/// types and pointers.
110/// The implementation will add up all unique `Buffer`'s memory
111/// size, due to:
112/// - The data pointer inside `Buffer` are memory regions returned by global memory
113/// allocator, those regions can't have overlap.
114/// - The actual used range of `ArrayRef`s inside `RecordBatch` can have overlap
115/// or reuse the same `Buffer`. For example: taking a slice from `Array`.
116///
117/// Example:
118/// For a `RecordBatch` with two columns: `col1` and `col2`, two columns are pointing
119/// to a sub-region of the same buffer.
120///
121/// {xxxxxxxxxxxxxxxxxxx} <--- buffer
122/// ^ ^ ^ ^
123/// | | | |
124/// col1->{ } | |
125/// col2--------->{ }
126///
127/// In the above case, `get_record_batch_memory_size` will return the size of
128/// the buffer, instead of the sum of `col1` and `col2`'s actual memory size.
129///
130/// Note: Current `RecordBatch`.get_array_memory_size()` will double count the
131/// buffer memory size if multiple arrays within the batch are sharing the same
132/// `Buffer`. This method provides temporary fix until the issue is resolved:
133/// <https://github.com/apache/arrow-rs/issues/6439>
134pub fn get_record_batch_memory_size(batch: &RecordBatch) -> usize {
135 RecordBatchMemoryCounter::new().count_batch(batch)
136}
137
138/// Tracks the memory used by a sequence of [`RecordBatch`]es that may share
139/// underlying buffers, counting each buffer exactly once.
140///
141/// Use this instead of [`get_record_batch_memory_size`] to account for the
142/// total memory of a sequence of batches, e.g. when buffering the batches of
143/// an input stream. Such batches can share buffers (for example, operators
144/// like aggregates emit one large batch as multiple zero-copy slices), and
145/// calling [`get_record_batch_memory_size`] per batch counts the shared
146/// buffers once per batch, while this counter counts them exactly once. A
147/// batch's buffers are kept alive by the batch even when only a sub-range is
148/// referenced, so counting unique buffers in full reflects the memory the
149/// batches actually retain.
150#[derive(Debug, Default)]
151pub struct RecordBatchMemoryCounter {
152 /// Start addresses of `Buffer`s that have already been counted (instead of
153 /// actual used data region's pointer represented by current `Array`)
154 counted_buffers: HashSet<NonZero<usize>>,
155 /// Total memory of all unique buffers counted so far
156 memory_usage: usize,
157}
158
159impl RecordBatchMemoryCounter {
160 pub fn new() -> Self {
161 Self::default()
162 }
163
164 /// Count `batch`, returning the memory used by its buffers that have not
165 /// been counted before.
166 pub fn count_batch(&mut self, batch: &RecordBatch) -> usize {
167 let mut total_size = 0;
168
169 for array in batch.columns() {
170 let array_data = array.to_data();
171 count_array_data_memory_size(
172 &array_data,
173 &mut self.counted_buffers,
174 &mut total_size,
175 );
176 }
177
178 self.memory_usage += total_size;
179 total_size
180 }
181
182 /// Total memory of the unique buffers of all batches counted so far.
183 pub fn memory_usage(&self) -> usize {
184 self.memory_usage
185 }
186}
187
188/// Count the memory usage of `array_data` and its children recursively.
189fn count_array_data_memory_size(
190 array_data: &ArrayData,
191 counted_buffers: &mut HashSet<NonZero<usize>>,
192 total_size: &mut usize,
193) {
194 // Count memory usage for `array_data`
195 for buffer in array_data.buffers() {
196 if counted_buffers.insert(buffer.data_ptr().addr()) {
197 *total_size += buffer.capacity();
198 } // Otherwise the buffer's memory is already counted
199 }
200
201 if let Some(null_buffer) = array_data.nulls()
202 && counted_buffers.insert(null_buffer.inner().inner().data_ptr().addr())
203 {
204 *total_size += null_buffer.inner().inner().capacity();
205 }
206
207 // Count all children `ArrayData` recursively
208 for child in array_data.child_data() {
209 count_array_data_memory_size(child, counted_buffers, total_size);
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use std::{collections::HashSet, mem::size_of};
216
217 use super::estimate_memory_size;
218
219 #[test]
220 fn test_estimate_memory() {
221 // size (bytes): 48
222 let fixed_size = size_of::<HashSet<u32>>();
223
224 // estimated buckets: 16 = (8 * 8 / 7).next_power_of_two()
225 let num_elements = 8;
226 // size (bytes): 128 = 16 * 4 + 16 + 48
227 let estimated = estimate_memory_size::<u32>(num_elements, fixed_size).unwrap();
228 assert_eq!(estimated, 128);
229
230 // estimated buckets: 64 = (40 * 8 / 7).next_power_of_two()
231 let num_elements = 40;
232 // size (bytes): 368 = 64 * 4 + 64 + 48
233 let estimated = estimate_memory_size::<u32>(num_elements, fixed_size).unwrap();
234 assert_eq!(estimated, 368);
235 }
236
237 #[test]
238 fn test_estimate_memory_overflow() {
239 let num_elements = usize::MAX;
240 let fixed_size = size_of::<HashSet<u32>>();
241 let estimated = estimate_memory_size::<u32>(num_elements, fixed_size);
242
243 assert!(estimated.is_err());
244 }
245}
246
247#[cfg(test)]
248mod record_batch_tests {
249 use super::*;
250 use arrow::array::{Float64Array, Int32Array, ListArray};
251 use arrow::datatypes::{DataType, Field, Int32Type, Schema};
252 use std::sync::Arc;
253
254 #[test]
255 fn test_get_record_batch_memory_size() {
256 let schema = Arc::new(Schema::new(vec![
257 Field::new("ints", DataType::Int32, true),
258 Field::new("float64", DataType::Float64, false),
259 ]));
260
261 let int_array =
262 Int32Array::from(vec![Some(1), Some(2), Some(3), Some(4), Some(5)]);
263 let float64_array = Float64Array::from(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
264
265 let batch = RecordBatch::try_new(
266 schema,
267 vec![Arc::new(int_array), Arc::new(float64_array)],
268 )
269 .unwrap();
270
271 let size = get_record_batch_memory_size(&batch);
272 assert_eq!(size, 60);
273 }
274
275 #[test]
276 fn test_get_record_batch_memory_size_with_null() {
277 let schema = Arc::new(Schema::new(vec![
278 Field::new("ints", DataType::Int32, true),
279 Field::new("float64", DataType::Float64, false),
280 ]));
281
282 let int_array = Int32Array::from(vec![None, Some(2), Some(3)]);
283 let float64_array = Float64Array::from(vec![1.0, 2.0, 3.0]);
284
285 let batch = RecordBatch::try_new(
286 schema,
287 vec![Arc::new(int_array), Arc::new(float64_array)],
288 )
289 .unwrap();
290
291 let size = get_record_batch_memory_size(&batch);
292 assert_eq!(size, 100);
293 }
294
295 #[test]
296 fn test_get_record_batch_memory_size_empty() {
297 let schema = Arc::new(Schema::new(vec![Field::new(
298 "ints",
299 DataType::Int32,
300 false,
301 )]));
302
303 let int_array: Int32Array = Int32Array::from(vec![] as Vec<i32>);
304 let batch = RecordBatch::try_new(schema, vec![Arc::new(int_array)]).unwrap();
305
306 let size = get_record_batch_memory_size(&batch);
307 assert_eq!(size, 0, "Empty batch should have 0 memory size");
308 }
309
310 #[test]
311 fn test_get_record_batch_memory_size_shared_buffer() {
312 let original = Int32Array::from(vec![1, 2, 3, 4, 5]);
313 let slice1 = original.slice(0, 3);
314 let slice2 = original.slice(2, 3);
315
316 let schema_origin = Arc::new(Schema::new(vec![Field::new(
317 "origin_col",
318 DataType::Int32,
319 false,
320 )]));
321 let batch_origin =
322 RecordBatch::try_new(schema_origin, vec![Arc::new(original)]).unwrap();
323
324 let schema = Arc::new(Schema::new(vec![
325 Field::new("slice1", DataType::Int32, false),
326 Field::new("slice2", DataType::Int32, false),
327 ]));
328
329 let batch_sliced =
330 RecordBatch::try_new(schema, vec![Arc::new(slice1), Arc::new(slice2)])
331 .unwrap();
332
333 let size_origin = get_record_batch_memory_size(&batch_origin);
334 let size_sliced = get_record_batch_memory_size(&batch_sliced);
335
336 assert_eq!(size_origin, size_sliced);
337 }
338
339 #[test]
340 fn test_record_batch_memory_counter_buffer_shared_across_batches() {
341 let schema = Arc::new(Schema::new(vec![Field::new(
342 "ints",
343 DataType::Int32,
344 false,
345 )]));
346
347 let int_array = Int32Array::from(vec![1, 2, 3, 4, 5, 6]);
348 let batch = RecordBatch::try_new(schema, vec![Arc::new(int_array)]).unwrap();
349 let slices = [batch.slice(0, 2), batch.slice(2, 2), batch.slice(4, 2)];
350
351 // Counting each slice individually counts the shared buffer once per slice
352 let summed: usize = slices.iter().map(get_record_batch_memory_size).sum();
353 assert_eq!(summed, 3 * get_record_batch_memory_size(&batch));
354
355 // A counter shared across the batches counts it exactly once
356 let mut counter = RecordBatchMemoryCounter::new();
357 let deduped: usize = slices.iter().map(|slice| counter.count_batch(slice)).sum();
358 assert_eq!(deduped, get_record_batch_memory_size(&batch));
359 assert_eq!(counter.memory_usage(), get_record_batch_memory_size(&batch));
360 }
361
362 #[test]
363 fn test_get_record_batch_memory_size_nested_array() {
364 let schema = Arc::new(Schema::new(vec![
365 Field::new(
366 "nested_int",
367 DataType::List(Arc::new(Field::new_list_field(DataType::Int32, true))),
368 false,
369 ),
370 Field::new(
371 "nested_int2",
372 DataType::List(Arc::new(Field::new_list_field(DataType::Int32, true))),
373 false,
374 ),
375 ]));
376
377 let int_list_array = ListArray::from_iter_primitive::<Int32Type, _, _>(vec![
378 Some(vec![Some(1), Some(2), Some(3)]),
379 ]);
380
381 let int_list_array2 = ListArray::from_iter_primitive::<Int32Type, _, _>(vec![
382 Some(vec![Some(4), Some(5), Some(6)]),
383 ]);
384
385 let batch = RecordBatch::try_new(
386 schema,
387 vec![Arc::new(int_list_array), Arc::new(int_list_array2)],
388 )
389 .unwrap();
390
391 let size = get_record_batch_memory_size(&batch);
392 assert_eq!(size, 8208);
393 }
394}