Skip to content

Commit f92d020

Browse files
authored
fix: Use smaller buffers during merging (#71)
`MergeOptimizedInvertedIndexReader` was added in #32 in order to avoid making small reads to our underlying `FileHandle`. It did so by reading the entire content of the posting lists and positions at open time. As that PR says: > A likely downside to this approach is that now pg_search will be, indirectly, holding onto a lot of heap-allocated memory that was read from its block storage. Perhaps in the (near) future we can further optimize the new `MergeOptimizedInvertedIndexReader` such that it pages in blocks of a few megabytes at a time, on demand, rather than the whole file. This PR makes that change. But it additionally removes code that was later added in #47 to borrow individual entries rather than creating `OwnedBytes` for them. I believe that this code was added due to a misunderstanding: `OwnedBytes` is a total misnomer: the bytes are not "owned": they are immutably borrowed and reference counted. An `OwnedBytes` object can be created for any type which derefs to a slice of bytes, and can be cheaply cloned and sliced. So there is no need to actually borrow _or_ copy the buffer under the `OwnedBytes`. Removing the code that was doing so allows us to safely recreate our buffer without worrying about the lifetimes of buffers that we've handed out.
1 parent 3dcacaf commit f92d020

11 files changed

Lines changed: 136 additions & 748 deletions

common/src/buffered_file_slice.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use std::cell::RefCell;
2+
use std::cmp::min;
3+
use std::io;
4+
use std::ops::Range;
5+
6+
use super::file_slice::FileSlice;
7+
use super::{HasLen, OwnedBytes};
8+
9+
const DEFAULT_BUFFER_MAX_SIZE: usize = 4 * 1024 * 1024; // 4 MB
10+
11+
/// A buffered reader for a FileSlice.
12+
///
13+
/// Reads the underlying `FileSlice` in large, sequential chunks to amortize
14+
/// the cost of `read_bytes` calls, while keeping peak memory usage under control.
15+
///
16+
/// TODO: Rather than wrapping a `FileSlice` in buffering, it will usually be better to adjust a
17+
/// `FileHandle` to directly handle buffering itself (as that allows separate `FileSlice`s read
18+
/// from the same `FileHandle` to share buffers.)
19+
pub struct BufferedFileSlice {
20+
file_slice: FileSlice,
21+
buffer: RefCell<OwnedBytes>,
22+
buffer_range: RefCell<Range<u64>>,
23+
buffer_max_size: usize,
24+
}
25+
26+
impl BufferedFileSlice {
27+
/// Creates a new `BufferedFileSlice`.
28+
///
29+
/// The `buffer_max_size` is the amount of data that will be read from the
30+
/// `FileSlice` on a buffer miss.
31+
pub fn new(file_slice: FileSlice, buffer_max_size: usize) -> Self {
32+
Self {
33+
file_slice,
34+
buffer: RefCell::new(OwnedBytes::empty()),
35+
buffer_range: RefCell::new(0..0),
36+
buffer_max_size,
37+
}
38+
}
39+
40+
/// Creates a new `BufferedFileSlice` with a default buffer max size.
41+
pub fn new_with_default_buffer_size(file_slice: FileSlice) -> Self {
42+
Self::new(file_slice, DEFAULT_BUFFER_MAX_SIZE)
43+
}
44+
45+
/// Creates an empty `BufferedFileSlice`.
46+
pub fn empty() -> Self {
47+
Self::new(FileSlice::empty(), 0)
48+
}
49+
50+
/// Returns an `OwnedBytes` corresponding to the given `required_range`.
51+
///
52+
/// If the requested range is not in the buffer, this will trigger a read
53+
/// from the underlying `FileSlice`.
54+
///
55+
/// If the requested range is larger than the buffer_max_size, it will be read directly from the
56+
/// source without buffering.
57+
///
58+
/// # Errors
59+
///
60+
/// Returns an `io::Error` if the underlying read fails or the range is
61+
/// out of bounds.
62+
pub fn get_bytes(&self, required_range: Range<u64>) -> io::Result<OwnedBytes> {
63+
let buffer_range = self.buffer_range.borrow();
64+
65+
// Cache miss condition: the required range is not fully contained in the current buffer.
66+
if required_range.start < buffer_range.start || required_range.end > buffer_range.end {
67+
drop(buffer_range); // release borrow before mutating
68+
69+
if required_range.end > self.file_slice.len() as u64 {
70+
return Err(io::Error::new(
71+
io::ErrorKind::UnexpectedEof,
72+
"Requested range extends beyond the end of the file slice.",
73+
));
74+
}
75+
76+
if (required_range.end - required_range.start) as usize > self.buffer_max_size {
77+
// This read is larger than our buffer max size.
78+
// Read it directly and bypass the buffer to avoid churning.
79+
return self
80+
.file_slice
81+
.read_bytes_slice(required_range.start as usize..required_range.end as usize);
82+
}
83+
84+
let new_buffer_start = required_range.start;
85+
let new_buffer_end = min(
86+
new_buffer_start + self.buffer_max_size as u64,
87+
self.file_slice.len() as u64,
88+
);
89+
let read_range = new_buffer_start..new_buffer_end;
90+
91+
let new_buffer = self
92+
.file_slice
93+
.read_bytes_slice(read_range.start as usize..read_range.end as usize)?;
94+
95+
self.buffer.replace(new_buffer);
96+
self.buffer_range.replace(read_range);
97+
}
98+
99+
// Now the data is guaranteed to be in the buffer.
100+
let buffer = self.buffer.borrow();
101+
let buffer_range = self.buffer_range.borrow();
102+
let local_start = (required_range.start - buffer_range.start) as usize;
103+
let local_end = (required_range.end - buffer_range.start) as usize;
104+
Ok(buffer.slice(local_start..local_end))
105+
}
106+
}

common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub use byteorder::LittleEndian as Endianness;
66

77
mod bitset;
88
pub mod bounds;
9+
pub mod buffered_file_slice;
910
mod byte_count;
1011
mod datetime;
1112
pub mod file_slice;

src/directory/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mod composite_file;
1919
use std::io::BufWriter;
2020
use std::path::PathBuf;
2121

22+
pub use common::buffered_file_slice::BufferedFileSlice;
2223
pub use common::file_slice::{FileHandle, FileSlice};
2324
pub use common::{AntiCallToken, OwnedBytes, TerminatingWrite};
2425

src/index/merge_optimized_inverted_index_reader.rs

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
11
use std::io;
22

3-
use common::OwnedBytes;
4-
5-
use crate::directory::FileSlice;
6-
use crate::positions::borrowed_position_reader::BorrowedPositionReader;
7-
use crate::postings::borrowed_block_segment_postings::BorrowedBlockSegmentPostings;
8-
use crate::postings::borrowed_segment_postings::BorrowedSegmentPostings;
9-
use crate::postings::TermInfo;
3+
use crate::directory::{BufferedFileSlice, FileSlice};
4+
use crate::positions::PositionReader;
5+
use crate::postings::{BlockSegmentPostings, SegmentPostings, TermInfo};
106
use crate::schema::IndexRecordOption;
117
use crate::termdict::TermDictionary;
128

139
/// The inverted index reader is in charge of accessing
1410
/// the inverted index associated with a specific field.
1511
///
16-
/// This is optimized for merging in that it full reads
17-
/// the postings and positions files into memory when opened.
18-
/// This eliminates all disk I/O to these files during merging.
12+
/// This is optimized for merging in that it uses a buffered reader
13+
/// for the postings and positions files.
14+
/// This eliminates most disk I/O to these files during merging, without
15+
/// reading the entire file into memory at once.
1916
///
2017
/// NB: This is a copy/paste from [`InvertedIndexReader`] and trimmed
2118
/// down to only include the methods required by the merge process.
2219
pub(crate) struct MergeOptimizedInvertedIndexReader {
2320
termdict: TermDictionary,
24-
postings_bytes: OwnedBytes,
25-
positions_bytes: OwnedBytes,
21+
postings_reader: BufferedFileSlice,
22+
positions_reader: BufferedFileSlice,
2623
record_option: IndexRecordOption,
2724
}
2825

@@ -36,8 +33,8 @@ impl MergeOptimizedInvertedIndexReader {
3633
let (_, postings_body) = postings_file_slice.split(8);
3734
Ok(MergeOptimizedInvertedIndexReader {
3835
termdict,
39-
postings_bytes: postings_body.read_bytes()?,
40-
positions_bytes: positions_file_slice.read_bytes()?,
36+
postings_reader: BufferedFileSlice::new_with_default_buffer_size(postings_body),
37+
positions_reader: BufferedFileSlice::new_with_default_buffer_size(positions_file_slice),
4138
record_option,
4239
})
4340
}
@@ -47,8 +44,8 @@ impl MergeOptimizedInvertedIndexReader {
4744
pub fn empty(record_option: IndexRecordOption) -> MergeOptimizedInvertedIndexReader {
4845
MergeOptimizedInvertedIndexReader {
4946
termdict: TermDictionary::empty(),
50-
postings_bytes: FileSlice::empty().read_bytes().unwrap(),
51-
positions_bytes: FileSlice::empty().read_bytes().unwrap(),
47+
postings_reader: BufferedFileSlice::empty(),
48+
positions_reader: BufferedFileSlice::empty(),
5249
record_option,
5350
}
5451
}
@@ -66,9 +63,11 @@ impl MergeOptimizedInvertedIndexReader {
6663
&self,
6764
term_info: &TermInfo,
6865
requested_option: IndexRecordOption,
69-
) -> io::Result<BorrowedBlockSegmentPostings> {
70-
let postings_data = &self.postings_bytes[term_info.postings_range.clone()];
71-
BorrowedBlockSegmentPostings::open(
66+
) -> io::Result<BlockSegmentPostings> {
67+
let postings_data = self.postings_reader.get_bytes(
68+
term_info.postings_range.start as u64..term_info.postings_range.end as u64,
69+
)?;
70+
BlockSegmentPostings::open(
7271
term_info.doc_freq,
7372
postings_data,
7473
self.record_option,
@@ -84,20 +83,22 @@ impl MergeOptimizedInvertedIndexReader {
8483
&self,
8584
term_info: &TermInfo,
8685
option: IndexRecordOption,
87-
) -> io::Result<BorrowedSegmentPostings> {
86+
) -> io::Result<SegmentPostings> {
8887
let option = option.downgrade(self.record_option);
8988

9089
let block_postings = self.read_block_postings_from_terminfo(term_info, option)?;
9190
let position_reader = {
9291
if option.has_positions() {
93-
let positions_data = &self.positions_bytes[term_info.positions_range.clone()];
94-
let position_reader = BorrowedPositionReader::open(positions_data)?;
92+
let positions_data = self.positions_reader.get_bytes(
93+
term_info.positions_range.start as u64..term_info.positions_range.end as u64,
94+
)?;
95+
let position_reader = PositionReader::open(positions_data)?;
9596
Some(position_reader)
9697
} else {
9798
None
9899
}
99100
};
100-
Ok(BorrowedSegmentPostings::from_block_postings(
101+
Ok(SegmentPostings::from_block_postings(
101102
block_postings,
102103
position_reader,
103104
))

src/indexer/merger.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ use crate::index::{Segment, SegmentComponent, SegmentReader};
1717
use crate::indexer::doc_id_mapping::{MappingType, SegmentDocIdMapping};
1818
use crate::indexer::segment_updater::CancelSentinel;
1919
use crate::indexer::SegmentSerializer;
20-
use crate::postings::borrowed_segment_postings::BorrowedSegmentPostings;
21-
use crate::postings::InvertedIndexSerializer;
20+
use crate::postings::{InvertedIndexSerializer, Postings, SegmentPostings};
2221
use crate::schema::{value_type_to_column_type, Field, FieldType, Schema};
2322
use crate::store::StoreWriter;
2423
use crate::termdict::{TermMerger, TermOrdinal};
@@ -373,8 +372,7 @@ impl IndexMerger {
373372
indexed. Have you modified the schema?",
374373
);
375374

376-
let mut segment_postings_containing_the_term: Vec<(usize, BorrowedSegmentPostings)> =
377-
vec![];
375+
let mut segment_postings_containing_the_term: Vec<(usize, SegmentPostings)> = vec![];
378376

379377
let mut cnt = 0;
380378
while merged_terms.advance() {

0 commit comments

Comments
 (0)