Skip to main content

openbim_step/
parallel.rs

1//! Parallel parsing with a result identical to [`parse_with`].
2//!
3//! The data section is split at byte offsets that look like record
4//! boundaries (`;` then optional whitespace then `#` and a digit), and each
5//! slice is parsed on its own thread.
6//!
7//! Correctness does not rest on that guess. Between two records, the
8//! sequential parser's entire state is its byte offset: phase `DATA`, no
9//! lookahead, the lexer just past a `;`. Each slice starts at the offset
10//! where the previous slice's parser *actually* stopped, and a slice only
11//! counts if its parser lands exactly on its end offset. If every slice
12//! does, each one saw precisely the state the sequential parser would have
13//! had there, so the concatenation is the sequential result. If any slice
14//! overshoots (the guess fell inside a string, comment, or a damaged record
15//! that recovery skips past) or fails, the attempt is discarded and the
16//! file is parsed sequentially. Errors therefore always come from the
17//! sequential parser and are identical by construction.
18//!
19//! Diagnostics are reproduced in the sequential order: slices and their
20//! diagnostics are concatenated in source order, and the reference check
21//! runs once over the merged records, so references across slices resolve
22//! and its final positional sort matches the sequential one.
23
24use crate::parser::{parse_chunk, parse_prefix, Chunk};
25use crate::references::ReferenceCheck;
26use crate::{parse_with, DataSection, Exchange, ParseOptions, ParseOutcome, StepError};
27
28/// Parses on up to `threads` threads, returning exactly what
29/// [`parse_with`] returns for the same input and options: the same
30/// exchange, the same diagnostics in the same order, the same error.
31///
32/// `threads <= 1` parses sequentially. Splitting pays off on large inputs
33/// only -- each thread starts with a fresh allocator arena and the merge is
34/// a move per record -- so callers should parse small files (below a few
35/// megabytes) with [`parse_with`] directly.
36///
37/// Worst case: when a split point turns out not to be a record boundary
38/// for the parser, the parallel work is discarded and the file is parsed
39/// again sequentially, costing up to one extra sequential parse.
40/// # Errors
41///
42/// Exactly the errors of [`parse_with`].
43pub fn parse_parallel_with(
44    input: &[u8],
45    options: ParseOptions,
46    threads: usize,
47) -> Result<ParseOutcome, StepError> {
48    if threads <= 1 || !crate::is_step_file(input) {
49        return parse_with(input, options);
50    }
51    try_parallel(input, options, threads).map_or_else(|| parse_with(input, options), Ok)
52}
53
54/// The parallel attempt; `None` means "use the sequential result".
55fn try_parallel(input: &[u8], options: ParseOptions, threads: usize) -> Option<ParseOutcome> {
56    let (header, data_start) = parse_prefix(input, options).ok()??;
57    let mut diagnostics = Vec::new();
58    let bounds = boundaries(input, data_start, threads);
59    let starts = std::iter::once(data_start).chain(bounds.iter().copied());
60    let ends = bounds
61        .iter()
62        .copied()
63        .map(Some)
64        .chain(std::iter::once(None));
65    let slices: Vec<(usize, Option<usize>)> = starts.zip(ends).collect();
66
67    let chunks: Vec<Result<Chunk, StepError>> = std::thread::scope(|scope| {
68        let handles: Vec<_> = slices
69            .iter()
70            .map(|&(start, end)| scope.spawn(move || parse_chunk(input, options, start, end)))
71            .collect();
72        handles
73            .into_iter()
74            .map(|handle| {
75                handle
76                    .join()
77                    .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
78            })
79            .collect()
80    });
81
82    let mut parsed = Vec::with_capacity(chunks.len());
83    for chunk in chunks {
84        let chunk = chunk.ok()?;
85        if !chunk.aligned {
86            return None;
87        }
88        parsed.push(chunk);
89    }
90
91    let mut references = options.check_references.then(ReferenceCheck::default);
92    let mut records = Vec::with_capacity(parsed.iter().map(|chunk| chunk.records.len()).sum());
93    for chunk in parsed {
94        // Slices are in source order, and so are the diagnostics within
95        // each, so plain concatenation is the sequential order. With the
96        // reference check on, `finish` re-sorts everything by position
97        // (stably) exactly as the sequential parse does.
98        diagnostics.extend(chunk.diagnostics);
99        for (record, span) in chunk.records.into_iter().zip(chunk.spans) {
100            if let Some(check) = &mut references {
101                check.record(&record, span, &mut diagnostics);
102            }
103            records.push(record);
104        }
105    }
106    if let Some(check) = references {
107        check.finish(&mut diagnostics);
108    }
109
110    Some(ParseOutcome {
111        exchange: Exchange {
112            header,
113            data: DataSection { records },
114        },
115        diagnostics,
116    })
117}
118
119/// Up to `threads - 1` strictly increasing split offsets after `data_start`,
120/// each just past a `;` that is followed by optional whitespace and `#`
121/// plus a digit. A guess only: the slice parsers verify every one.
122fn boundaries(input: &[u8], data_start: usize, threads: usize) -> Vec<usize> {
123    let step = (input.len() - data_start) / threads;
124    let mut out = Vec::with_capacity(threads - 1);
125    let mut from = data_start;
126    for k in 1..threads {
127        let target = data_start + step * k;
128        if target <= from {
129            continue;
130        }
131        match next_boundary(input, target) {
132            Some(boundary) => {
133                out.push(boundary);
134                from = boundary;
135            }
136            None => break,
137        }
138    }
139    out
140}
141
142fn next_boundary(input: &[u8], from: usize) -> Option<usize> {
143    let mut position = from;
144    while let Some(offset) = memchr::memchr(b';', &input[position..]) {
145        let after = position + offset + 1;
146        let rest = &input[after..];
147        let blank = rest
148            .iter()
149            .take_while(|byte| byte.is_ascii_whitespace())
150            .count();
151        if rest.get(blank) == Some(&b'#') && rest.get(blank + 1).is_some_and(u8::is_ascii_digit) {
152            return Some(after);
153        }
154        position = after;
155    }
156    None
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{boundaries, next_boundary, try_parallel};
162    use crate::parser::parse_chunk;
163    use crate::{parse_with, ParseOptions};
164    use std::fmt::Write as _;
165
166    /// Records with no literal that looks like a boundary, so every split
167    /// guess is a real boundary and the parallel path must be taken.
168    fn plain(n: usize) -> Vec<u8> {
169        let mut out = String::from(
170            "ISO-10303-21;HEADER;FILE_DESCRIPTION(('d'),'2;1');\
171FILE_NAME('n','t',('a'),('o'),'p','s','z');FILE_SCHEMA(('IFC4'));ENDSEC;DATA;\n",
172        );
173        for id in 1..=n {
174            writeln!(out, "#{id}=IFCPOINT(({id}.,#{}));", n + 1 - id)
175                .expect("writing to a String cannot fail");
176        }
177        out.push_str("ENDSEC;END-ISO-10303-21;\n");
178        out.into_bytes()
179    }
180
181    #[test]
182    fn clean_input_takes_the_parallel_path_and_matches() {
183        let input = plain(2_000);
184        for options in [
185            ParseOptions::strict(),
186            ParseOptions::lenient().check_references(true),
187        ] {
188            for threads in [2, 4, 8] {
189                let parallel =
190                    try_parallel(&input, options, threads).expect("clean input must not fall back");
191                assert_eq!(
192                    Ok(parallel),
193                    parse_with(&input, options),
194                    "threads={threads}"
195                );
196            }
197        }
198    }
199
200    #[test]
201    fn a_slice_whose_end_is_inside_a_record_stops_there_unaligned() {
202        let input = plain(2_000);
203        let text = std::str::from_utf8(&input).expect("ascii");
204        let start = text.find("#1=").expect("first record");
205        let middle = text.find("#1000=").expect("record 1000") + 3;
206        let chunk = parse_chunk(&input, ParseOptions::strict(), start, Some(middle))
207            .expect("parses up to the overshoot");
208        assert!(!chunk.aligned);
209        // It must stop at the first record past `end`, not run to the end
210        // of the file: that is wasted work on every misaligned split.
211        assert_eq!(chunk.records.len(), 1_000);
212    }
213
214    #[test]
215    fn a_boundary_is_just_past_a_semicolon_before_an_id() {
216        let input: &[u8] = b"DATA;#1=A();\n  #2=B('x;y');#3=C();";
217        let after = |needle: &[u8]| {
218            input
219                .windows(needle.len())
220                .position(|window| window == needle)
221                .expect("needle present")
222                + needle.len()
223        };
224        assert_eq!(next_boundary(input, 0), Some(after(b"DATA;")));
225        assert_eq!(next_boundary(input, 6), Some(after(b"#1=A();")));
226        // `;y` inside the string is not followed by `#digit`, so the next
227        // boundary after #2 starts is the end of #2.
228        assert_eq!(next_boundary(input, 16), Some(after(b"'x;y');")));
229        assert_eq!(next_boundary(input, input.len() - 3), None);
230    }
231
232    #[test]
233    fn boundaries_are_strictly_increasing_and_bounded() {
234        let mut input = b"DATA;".to_vec();
235        for id in 1..=100 {
236            input.extend_from_slice(format!("#{id}=A({id});").as_bytes());
237        }
238        for threads in [2, 3, 8, 64, 500] {
239            let cuts = boundaries(&input, 5, threads);
240            assert!(cuts.len() < threads);
241            assert!(cuts.windows(2).all(|pair| pair[0] < pair[1]));
242            assert!(cuts.iter().all(|&cut| cut > 5 && cut < input.len()));
243            assert!(cuts.iter().all(|&cut| input[cut - 1] == b';'));
244        }
245    }
246}