Skip to main content

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}