Conversation
…ndex full_buffer_impl::size(), get(), get_u32() and get_u64() (and their mmap_impl twins) were defined out of line, so every character the lexer read crossed a call boundary with its own bounds check. Callgrind put the three at 5.6% of parse self time; inlining them lets the compiler hoist the checks out of the scanning loops, which is worth more than their own cost. Also reserve the streamer's inverse vector from the file size (about one record per 32 bytes of SPF on real models) and shrink it once the bulk load is sorted, so the doubling copies and the capacity slack go away. Parse time, C++ file constructor, 12-core Linux box: TXG 58 MB 1.35 s -> 1.15 s 210_King 148 MB 3.70 s -> 3.09 s OKgate22 232 MB 6.18 s -> 5.30 s Python ifcopenshell.open(): 1.40 -> 1.22, 3.70 -> 3.24, 6.27 -> 5.52 s. Memory unchanged. The removed exported symbols mean the Python wrapper must be rebuilt against this library. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
|
Thanks. 013ebd2 This makes sense, no impact on complexity 10d7c4a I didn't know this existed and happy to merge it in 597ba12 Here I'm not so sure if we should roll our own. There's probably unknown performance anomalies in unexpected cases. At the very least we should compare with https://github.com/mpictor/judy-template Which is (not surprisingly?) from the StepCode people 8a5f751 I don't know if I like this. Did you consider a normal map with:
bf91629 Can you talk me through the rationale. I'm missing the mental model behind this making sense. This is peak mem during parse, but as soon as you start doing something with the model like interpreting geometry you'll blow past that peak in no time. Is this worth the added complexity? 2e163d7 I don't understand why you retain storage_ as a member. That adds 8 bytes with little utility. I don't think it's very pretty, but I guess this is similar to the machinery of existing low-level types so I guess it's a natural progression for what I started with this type. std::optional<> instead of naked pointer does have an impact on the memory consumption when we use RocksDB. While it's not ugly in code, I'm not 100% of this tradeoff - but I can accept it given that the idea of RocksDB is also to have less instances alive. ad76c74 We had lazy loading initially in a similar form with offsets and that's why capturing inverse references is still independent of building the attribute storage. But I think we can only accept this if the code paths are similar enough instead of a full new inline tokenizer on the side with all the new bugs and corner cases. Please investigate what this would look like if we do have the tokenizer (and preferably the streamer - Feel free to add templates so that the storage paths are discarded constexpr) shared between the two modes. 5816ac5 I didn't review this yet. In general I don't really like that we're working around the file reader modes and reverting back to a full read of the file. I do believe reading in pages is a necessity especially if we want to stream through massive files and doing a streaming conversion to rocksdb for ex. We're optimizing here for mid-range files at the expense of the massive files (that barely fit in RAM for ex.). For all above commits that concern this see if you can operate on larger ranges of bytes without a full raw char* hack. |
707bdf6 to
e853382
Compare
|
(Like the PR, this reply and every number in it were produced by an AI coding tool; Dion has not verified them.) Thanks. Housekeeping first: the first commit is #9474 on its own and mergeable. Fast-proxy is withdrawn from both PRs: dense_id_map. Benchmarked against judy-template on the three public models' actual name sets in file order, including 210_King whose names start at #3123171:
Judy is the most compact on sparse names and 7–14x slower to look up; the vector is a direct index. The anomaly you were worried about was real: the first version made names inserted before the vector grew past them unreachable (210_King, 867k silently dropped references). It is fixed with a regression test for that order, and the harness now counts logged parser messages so a silent drop cannot read as a speed-up. If you would rather not own the container, judy-template is defensible for sparse files; on these three it costs 20–90 ns per lookup and wins memory only on King. guid_map. Now In-place references. The rationale I failed to give: it is what lazy loading stands on. Materialising one instance on demand has to resolve that instance's references from its own slots; with a side table there is nothing per instance to resolve. On its own it is a memory change only: steady −25 / −72 / −88 MB on the three public models (aggregates are built exactly sized instead of copied out of 24-byte variants) and the parse peak −18%. Whether the peak matters depends on the deployment: geometry will exceed it, a server doing property or validation work will not. If lazy loading does not survive review, I agree the standalone case is memory only. Two allocations. The cached slot pointer is gone; the slots are computed from the size byte. The optional stays, as you accepted. Lazy loading and the tokenizer. Measured what sharing the tokenizer would cost, on all fourteen models (single thread, whole file): stage throughput on fourteen models
Ranges: the byte scan the lazy index uses runs at 715–1142 MB/s, the tokenizer at 148–207 MB/s (building nothing), the full parse at 54–78 MB/s. On TXG the scan is 0.06 s of a 0.41 s lazy open; the other 0.35 s builds the shells, sorts 1.66M inverse records and fills the GlobalId map, and that part does not change with the scanner. So an index pass through the shared tokenizer would cost about 4x the scan time on top: 0.41 → ~0.65 s on TXG, 1.09 → ~1.7 s on King, 5.1 → ~8.0 s on the 787 MB model, i.e. 60–70% of the strict single-thread parse instead of 30–45%. (The index pass can be chunked over threads the same way the strict parse is, which is not done yet.) I take the point about two scanners owning the same corner cases; I would rather agree the shape before writing it. What I would propose: a Streaming. This was the main design objection, so here is what changed and what it measures. Four steps, in commits 9–11:
What it measures (C++, one thread and 12 threads; full table in the PR body): fourteen models, v0.9.0 head against this branch, in-memory / paged / lazy
How this moves streaming toward being the default rather than a mode: before these commits the fast paths (lazy index, parallel chunking, and the strict parse's tokenizer fast paths) each took a Two honest caveats. One paged 12-thread parse of 210_King in the benchmark matrix logged two parser messages; 48 repeats of that configuration and of the 787 MB model logged zero. It is not reproduced and not root-caused, and I would not treat the parallel paged path as verified until it is. And on the 279 MB and 523 MB large-instance models, materialising every instance after a lazy open costs 1.6x the strict parse (523 MB: 2.0 + 9.5 s against 7.1 s; 0.97–1.18x on the other twelve); lazy is for touching a fraction of the file, not for iterating all of it. Parallel. Unchanged in design; it chunks over spans and gives each worker its own reader, so it runs on the paged reader too. |
I'm still not very comfortable. There's also still threading race conditions (not sure about the interaction with multi threaded parsing) we need to worry about and a whole variety of untested numbering schemes.
Just drop the other map. I think it's an ok requirement that only valid guids can be looked up.
I don't understand. Lazy means only 2nd pass resolution is skipped? In previous versions of ifcopenshell lazy meant the entire attribute vector is uninitialised and will be read from disk when needed - which I assumed you're also doing since you're storing the offset. But inverses were still built as we were streaming through the file. What is exactly lazy then?
But why not make the existing tokenizer faster then? Maybe similarly as you did on the file reader: inlining more. Conceptually there should be nothing unique about the lazy tokenizer that doesn't apply to the non-lazy one. Deferring floats parsing or making it multi-threaded is also things I have experimented with on top of the existing tokenizer. That's what I meant with: be a bit creative with templates so that you transform the existing tokenizer into the compile-time equivalent of the fully inline while loop you drafted. Feel free to also swap the producer mechanism, not a |
byguid_ was a std::map<std::string, ...>: a red-black node plus a heap-allocated 22-character string per rooted instance, and a lookup that walks ~18 levels of string comparisons on a 200k-entry file. guid_map keeps keys of up to 23 characters inline in an unordered_map node (every valid GlobalId is 22), and routes anything longer to an ordered map so invalid files still work. Same std::string-keyed interface as before. Parse, C++ file constructor, on top of the previous commits: TXG 58 MB 1.08 s -> 1.03 s 341 -> 335 MB 210_King 148 MB 2.80 s -> 2.72 s 836 -> 830 MB OKgate22 232 MB 4.25 s -> 3.94 s 1271 -> 1253 MB This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
… a side table While loading, every entity reference was appended to a vector of (owner, attribute, variant) entries, roughly 50 bytes each, with aggregates copied into a heap vector of 24-byte variants, and the attribute slot left blank until a final loop walked that table and copied everything into storage. On a 58 MB model the table and its vectors were 64 MB of the peak, freed only after parsing. References now stay where they are read: a scalar slot holds the referenced name (with its file offset for error messages), a list slot holds a vector of names at four bytes each, nested lists likewise, and simple type instances go straight in. Once every instance has been read, a pass over the instances in name order (and over the simple type instances, which carry the references of select-typed attributes such as IfcPropertySetDefinitionSet) swaps names for instances. Only aggregates that mix references with inline typed values, such as trimming selects, still use the table. The three transient slot types are appended to the parameter pack and to argument_type in lock step, so no existing index or RocksDB encoding moves, and no loaded file ever exposes them. The streamer keeps the table by default; read_from_stream() opts into in-place storage. The Python streaming wrapper and the RocksDB serializer, which consume references(), are unchanged. Parse, C++ file constructor, on top of the previous commits: TXG 58 MB 1.03 -> 0.96 s steady 335 -> 310 MB peak 470 -> 383 MB 210_King 148 MB 2.72 -> 2.74 s steady 830 -> 758 MB peak 1144 -> 948 MB OKgate22 232 MB 3.94 -> 3.70 s steady 1253 -> 1165 MB peak 1749 -> 1447 MB New tests cover scalar, list and nested-list references, a mixed IfcTrimmingSelect list, missing names in scalars and lists, and references to bypassed instances in slots and in mixed lists. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
Each instance was four separate allocations: the instance_data record, the attribute array object it pointed at, that array's index bytes, and its slot storage. A 58 MB model made 10.4 million mallocs to load 918k instances, and massif attributed 99 MB of its 450 MB peak to malloc bookkeeping alone. variant_array now allocates the size byte, the per-slot type indices and the slots as one block, and instance_data holds the array in a std::optional instead of behind a pointer (an empty optional keeps the meaning the null pointer had: attribute storage constructed on the fly from the RocksDB backend). No ownership or lifetime changes; the same object owns the same data. Parse, C++ file constructor, on top of the previous commits: TXG 58 MB 0.96 -> 0.94 s steady 310 -> 262 MB peak 383 -> 335 MB 210_King 148 MB 2.74 -> 2.63 s steady 758 -> 630 MB peak 948 -> 820 MB OKgate22 232 MB 3.70 -> 3.61 s steady 1165 -> 971 MB peak 1447 -> 1252 MB mallocs while loading TXG: 10.39M -> 7.56M. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
…True) Every instance's attributes were parsed at open, whether or not anything ever read them. Profiling put 82% of open time in that per-instance work, while the semantic workloads measured (walls with their property sets, the spatial tree) touch under 1.5% of a model's instances. With lazy loading on, one scan over the DATA section indexes the file: for each instance it records the name, the type and the offset of its attribute list, registers every reference it makes in the inverse index (tracking quote state, parenthesis depth and commas at depth one for the attribute index), and reads the GlobalId of rooted instances. Instances are created as shells without attribute storage. The first access to an instance's attributes (instance_data::ensure_loaded, called from get_attribute_value, has_attribute_value and set_attribute_value) seeks the retained reader to that offset and runs the same attribute reader the full parse uses, then resolves the references in place. Inverses are not registered again during that step. The scanner aborts on anything it does not handle (a stray token between instances, a semicolon inside an instance, an unterminated comment or string) and the file is then parsed in full, so the mode never changes what a file loads as. Types, GlobalIds and inverses come from the index, so by_type, by_guid and get_inverse work without parsing attributes. Reading every attribute of every instance costs what the full parse cost, spread over the reads. C++ open, 12-core Linux box, on top of the previous commits (full parse in brackets); "touch" reads one attribute of every instance afterwards: TXG 58 MB 0.29 s [0.94] 179 MB [262] touch +0.49 s 210_King 148 MB 0.84 s [2.63] 494 MB [630] touch +1.26 s OKgate22 232 MB 1.09 s [3.61] 676 MB [971] touch +1.85 s The retained source is a heap copy of the file for now; a memory-mapped source would make it file-backed and reclaimable. Tests: per-instance to_string(), inverse counts and GlobalId lookups of a lazily opened file equal the full parse's on the fixture and on an inline file with mixed selects, nested lists and missing names; the fallback engages on a stray token. Python: open(lazy=True) equality, editing and writing a lazy file, and the fallback. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
The per-instance reader is embarrassingly parallel; only the tables it
feeds are shared. read_from_stream() now splits the DATA section into one
chunk per thread at a '#' that starts a line (strings can't contain raw
newlines, so that is always an instance boundary; a comment in DATA makes
it fall back to the serial loop), runs the same reader in each worker
with its own storage, inverse records, mixed-reference table and simple
type instances, and merges the results in file order, so the outcome is
identical to the serial parse: same instance order, same GlobalId
precedence, same inverse records once sorted. Reference resolution then
splits over the same threads, since every instance's slots are its own
and the tables are complete and only read. The logger already locks.
file::parse_threads() sets the count; 0 (the default) uses one thread per
core capped at 16, or IFCOPENSHELL_PARSE_THREADS. Files under 2 MB per
thread stay serial. Workers reset the lexer's temporary-string pool after
every instance, as the streamer does; without that the pool grows across
the whole chunk.
C++ file constructor, 12-core Linux box, on top of the previous commits:
1 thread 4 threads 12 threads
TXG 58 MB 0.91 s 263 MB 0.44 s 265 MB 0.37 s 267 MB
210_King 148 MB 2.55 s 627 MB 1.18 s 634 MB 1.03 s 641 MB
OKgate22 232 MB 3.48 s 981 MB 1.71 s 985 MB 1.43 s 985 MB
Peak memory during the parse is 10-15% higher than serial from the
merge copies. Lazy loading is unaffected.
Test: a fixture replicated to 12 MB under renumbered names parses the
same with 1 and 5 threads (per-instance serialisation, inverse counts,
GlobalId lookups, max id).
This commit was written by an AI coding tool and has not been verified by
a human.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
The in-place reference lists carried only names, so an error about a reference to a missing instance from a list said where by instance and attribute but not by file offset. ifcopenshell.validate only relays parser messages that end in "at offset N", using the offset to quote the line, so those errors vanished from validation reports and test_validate.py::test_file[fail-expected-3-invalid-entity.ifc] counted two errors instead of three. Each list now keeps the offset of its first reference and reports it, so the message has the same form as for a scalar and points at the same line. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
… in parallel over pages The lazy index and the parallel chunker reached for a contiguous pointer to the whole file, so they only worked when the file was held in memory as a whole, and lazy loading kept a heap copy of the file alive as its source. file_reader::for_each_span(begin, end, fn) now hands out contiguous spans covering a byte range: one span for a contiguous implementation, one per page for the paged one. The lazy index is a state machine fed one span at a time, so a page boundary can fall inside anything (a name, a keyword, a string, a comment marker, ENDSEC) and it still records the same instances, references and GlobalIds. The lazy source is a paged reader with 64 KB pages and a 64-page cache instead of the whole file. The parallel chunker finds the DATA section, the comment check and the line-start '#' boundaries in one streaming pass, and each worker gets its own reader via file_reader::reopen(): a private page cache for the paged implementation, a shared read-only buffer otherwise. Lazy open, C++, on top of the previous commits (heap-copy source in brackets): TXG 58 MB 0.40 s [0.29] 125 MB [179] 210_King 148 MB 1.08 s [0.84] 353 MB [494] OKgate22 232 MB 1.49 s [1.09] 450 MB [676] The slower open is the header streamer and the scan running over pages. The tokenizer itself is the next obstacle to paged reading as the default: over the whole file it runs at 200 MB/s on the in-memory buffer and at 35-43 MB/s through the paged reader, since every byte goes through a page lookup. A cursor fast path for the current page is the follow-up. The strict parse still reads the file into memory; that is unchanged here. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
Every peek(), get() and SWAR word read on a paged reader went through the
page cache's hash map and LRU list, so tokenizing through pages ran at a
fifth of the speed of the in-memory buffer. The reader now remembers the
page its cursor was last on and serves reads that fall inside it from the
pointer, revalidated against an eviction counter on the implementation
so a page that left the cache is never read through a stale pointer.
Reads that straddle a page boundary take the existing paths.
The whole tokenizer over each file, 64 KB pages, 64 cached (4 MB):
in-memory buffer paged before paged after
TXG 58 MB 200 MB/s 42 MB/s 142 MB/s
210_King 148 MB 181 MB/s 34 MB/s 118 MB/s
OKgate22 232 MB 197 MB/s 38 MB/s 128 MB/s
This is the step that makes reading in pages a candidate for the default
path rather than a fallback; the remaining gap is the page fetch itself.
This commit was written by an AI coding tool and has not been verified by
a human.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
file::paged_reading(true) before initialize() makes the full parse read the file through the paged reader (64 KB pages, 4 MB cache) instead of loading it into memory as a whole. With the current-page fast path and the per-worker readers of the previous commits this is the same code path, serial or parallel; the equality test now checks the paged reader against the in-memory one, serially and with five workers. TXG 58 MB, C++ file constructor, in-memory reader in brackets: 1 thread 1.07 s [0.94] peak 287 MB [335] 12 threads 0.43 s [0.41] peak 357 MB [363] The saving is the file buffer; the cost is the page fetch, hidden by threads. Measured across more files in the PR discussion. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
The lazy index no longer has a byte scanner of its own. spf_lexer gains scan_attributes(sink), the consumer-driven counterpart of next(): it walks one instance's attribute list from just past the opening parenthesis to just past the terminating semicolon, decoding nothing, reports each reference with the index of the attribute it sits in and the first attribute's raw text when it is a string, and goes through the same whitespace and comment helpers as next(). Instance headers are read as tokens by for_each_instance_header(), which the parallel chunker and the lazy index now share, so both resolve declarations, log unknown types and collect bypassed instances the same way. file_reader::span() hands out the bytes at the cursor that are contiguous in memory, the rest of the buffer or the rest of the current page. The scan walks a span with a plain pointer, eight bytes at a time while none of them matters, and only continues through the cursor for what a span cannot finish (a string, a reference, a comment), so it runs the same over pages as over a buffer. The SWAR helpers move to swar.h so the scan can use them from the header. The scan checks what the byte scanner checked: a lone slash, a semicolon inside an instance, a reference without digits, a file that ends inside an instance or a string, and a keyword between instances all stop the index and the file is parsed in full. TXG (58 MB), single thread: index pass 406 MB/s in memory and 218 MB/s over 64 KB pages, against the byte scanner's 1060 MB/s; lazy open 0.52 s against 0.41 s, because building the shells, the inverse index and the GlobalId map is most of a lazy open, not the scan. The next commit recovers the difference in the tokenizer itself. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
callgrind on the tokenizer showed SWAR::has_special_char and eq_mask compiled as calls, one per eight bytes; paged_file_impl::size() out of line behind every eof() and remaining(); and the cursor's page-cache check not inlined into peek() because it shared a function with the page fetch. The SWAR helpers are forced inline, size() is defined in the class, and cached_() is split into an inline check and an out-of-line refresh. TXG (58 MB), single thread: tokenizer 196 -> 204 MB/s in memory and 136 -> 193 MB/s through 64 KB pages; strict parse through pages 1.15 -> 0.97 s against 0.93 s in memory; lazy index pass over pages 218 -> 311 MB/s; lazy open 0.52 -> 0.43 s. This commit was written by an AI coding tool and has not been verified by a human. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
|
(Like the PR, this reply and every number in it were produced by an AI coding tool; Dion has not verified them.) Thanks, that was the right push. Point by point, then the numbers. What lazy is. I described it badly. A lazy open does one pass over the DATA section through the tokenizer and builds everything that indexes the file: a shell per instance (name, declaration, the file offset of its attribute list, no attribute array), the complete inverse index with attribute indices, the GlobalId map, the by-type lists. It decodes no attribute values: no strings, no numbers, no aggregates. The first time anything touches an instance's attributes, In-place references, and your peak-memory question. Before this branch, when the parser met You are right that for geometry the first effect is irrelevant: geometry allocates far beyond the parse peak, so the process maximum is set by geometry and only the steady saving carries through (geometry sits on top of the retained model). Where the parse peak is the process maximum, which is validation, property and quantity reads, IDS, edits and re-serialisation, and any server that packs several such jobs on one box, the peak saving is a real saving. So: peak saving for non-geometry workloads, steady saving for all workloads. On TXG: peak 463 → 373 MB (318 MB through the paged reader), steady 357 → 283 MB. Lazy loading wants the in-place form because it materialises one instance at a time and has to resolve that instance's references right then; with placeholders in its own slots that is a walk over the slots. A side table kept alive for the life of the file, or one per instance, would also work; in-place is the simpler and cheaper form, not a hard dependency. I overstated that as "stands on" earlier. The shared tokenizer. Done, the byte scanner is deleted (commit 11). Making the tokenizer faster instead. Also done, and you were right that it is where the time was. callgrind on the shared tokenizer showed The index pass through the shared tokenizer runs at 351–527 MB/s in memory and 248–476 MB/s over pages, against 715–1142 MB/s for the byte scanner. Lazy open pays 10–20% for that on the same map, because building the shells, inverses and GlobalIds is most of a lazy open, not the scan. guid_map. The ordered-map fallback is gone; only a 22-character key can be stored or found. One existing Python test looked up a wall by the GlobalId "id"; it now uses a real one and checks that a short key is rejected. dense_id_map. Withdrawn, as you were still uncomfortable. On threading: the table was written only in the serial merge and by
With 3.8 million names the hash map is cache-miss bound on every reference lookup. If that cost is acceptable, fine; if not, a fuzz test over random numbering schemes checked against a plain map is what I would add before proposing it again. A correction. My previous comment reported two parser messages on a few parallel runs and said I would not trust the parallel path until explained. It was my benchmark harness: the line counter iterated between Before and after, every model (C++ first, then Python; percentages against v0.9.0 head in the same row):
Parallel. Unchanged in design; it now runs on the shared header loop and each worker has its own paged reader. |
Clear. I don't understand how these are later unified with correct ordering. How is this correctly (in correct order) interleaved with simple type refs (inline entity type and arglist) and entity instance refs (numeric ref). |
|
(AI-written, not verified by Dion.) Short answer: the order is never re-derived, and mixed lists never go in place. The tokenizer already hands
List-of-lists is the same decision applied to all inner lists at once (L976-L1003): in place only when every inner list is all names (or all inline values), otherwise the table. Resolution of the in-place forms is a walk over one instance's slots (parse.cpp#L2532-L2596): a scalar placeholder becomes the instance or Inline typed values that themselves contain references (the Covered by the fixture in the test (test_ifcopenshell_parse.cpp#L385-L426): |
I still see several while() loops that loop over raw file bytes IfcOpenShell/src/ifcparse/parse.cpp Line 151 in db71cb3 IfcOpenShell/src/ifcparse/parse.h Line 127 in db71cb3 that do all sorts of overlapping and hard to verify re-interpretations of the SPF grammar. I want one place to be responsible for tokenisation and then C++ templates and compiler optimisations to handle that as efficiently as possible based on context. Can we do one more fresh series of commits where we incorporate the lessons learned, centralise the logic of tokenisation and in one by one minimal commits apply the improvements. I do not think there need to be 2000 lines added. We're looking at (1) inlining and minimal optimizations, global id map, heap alloc reduction (2) more template context to existing tokenization to enable compile-time inlining and short circuiting (3) unresolved reference storage (a bit in doubt if we shouldn't make that the existing variant of (int ref | resolved simple type) so that the change can be even more local and lazy loading - bit in doubt if that should be template instead so that we do not pay for the offset overhead in full parsing (4) multi threading. I'm not opposed to the swig optimizations. I don't think we should let the existing template stub test dictate performance, better add the alternative signature extraction (if possible) or runtime introspection (is possible). |
|
The fresh series is up as #9492: your order, one tokenizer (the index is |
Exactly. This one of these corner cases that a lot of implementations trip over. SPF is not a regular language - do not use regexes or quote counting techniques. |
|
(Dion here now) I reckon we can close this one because it's completely superseded? |
Before and after
C++
fileconstructor on fourteen models, three public (TXG, 210_King, OKgate22) and eleven supplied privately for benchmarking and identified by size only. Time is wall clock on a 12-core Linux box; memory is anonymous RSS after the parse, peak is VmHWM; percentages are against v0.9.0 head (148ff02) in the same row.Python
ifcopenshell.openon the same fourteen models, wall clock and RSS growth over the bare interpreter as a multiple of file size. This branch's default open is the parallel strict parse. v0.8.5 is the last release; v0.9.0 head is a wrapper built from 148ff02 with the same options.Why
ifcopenshell.open()on large models is slow and uses about six bytes of memory per byte of file. Profiling a 58 MB IFC4 model (918k instances) with callgrind and massif on v0.9.0:What
Twelve commits, each independently testable. The first is #9474 on its own. Withdrawn after review: SWIG fast-proxy (breaks
validate_stub) anddense_id_map(a vector-indexed instance-name table; its cost is stated below so it can be reconsidered with numbers).guid_map:unordered_mapkeyed bystd::array<char, 22>; only a 22-character GlobalId can be stored or foundfile::lazy_loading(true),open(path, lazy=True))IFCOPENSHELL_PARSE_THREADS)ifcopenshell.validatekeys on)file_reader::for_each_span/reopen; paged lazy source with a 4 MB cache; span-based parallel chunking with a reader per workerfile::paged_reading(true)), serial or parallel; the whole file is never read into memoryspf_lexer::scan_attributes(sink),file_reader::span(), one instance-header loop for the parallel chunker and the lazy index; the byte scanner is deletedsize(), the cursor's page-cache check)What lazy loading is, precisely
A lazy open does one pass over the DATA section through the tokenizer and builds everything that indexes the file: an instance shell for every
#name = TYPE((name, declaration, the file offset of its attribute list; no attributes), the complete inverse index (every#referencewith the index of the attribute it sits in, soget_inverseworks), the GlobalId map (the first attribute of every IfcRoot subtype), and the by-type lists. Attribute values are not decoded at all: no strings, no numbers, no aggregates, nothing allocated for them. That is why a lazy open holds 128–157 bytes per instance whatever the file.An instance's attributes are read from disk the first time anything touches them:
ensure_loaded()seeks the paged reader to the recorded offset and runs the sameload_attributes()the strict parse runs, then resolves that instance's references from its own slots (this is the one thing in-place reference resolution is used for; a per-instance side table would also work, in-place is the cheaper form). Inverse registration is off during that read because the index already has them. So "lazy" is what it was in earlier versions of IfcOpenShell: offsets, inverses built while streaming, attributes from disk on demand; not "skip the second pass". Writing works because a modified instance is materialised first. Reading every attribute of every instance costs about the strict parse, spread over the reads (see the throughput table).Results on fourteen models
Three public models (TXG, 210_King, OKgate22) and eleven models of 10 MB to 787 MB supplied privately for benchmarking; they are identified by size only and nothing from their contents is reported. C++
fileconstructor, 12-core Linux box, one run each, in the same process order for every mode. Memory is anonymous RSS after the parse / peak RSS (VmHWM), in MB. Every run logs zero parser messages (an earlier version of this table reported two messages on a few parallel runs; that was undefined behaviour in the benchmark's own line counter, not the parser, verified by echoing every log write).Reading across the columns:
Throughput of the stages, single thread, whole file, and the cost of reading one attribute of every instance right after a lazy open:
The index pass through the shared tokenizer runs at 351–527 MB/s in memory and 248–476 MB/s over 64 KB pages, against 715–1142 MB/s for the byte scanner it replaced; the tokenizer itself at 147–207 MB/s in memory and 147–198 MB/s through pages (87–100% of in-memory; it was 66–77% before commit 12 and 17–21% before commit 9). Materialising every instance after a lazy open costs 0.95–1.06x the strict single-thread parse on eleven models, 1.28x on OKgate22 and 1.37–1.38x on the 279 MB and 523 MB ones.
End to end from Python (
ifcopenshell.open, RSS growth over the bare interpreter, default = parallel strict). v0.8.5 is the last release; v0.9.0 head from Python was measured on the three public models: 1.40 / 3.70 / 6.27 s at 6.0–6.2x file size, peak 7.5–7.8x.The cost of the withdrawn
dense_id_mapSame code with and without the vector-indexed name table (branch
open-perf-dense-ids), so the difference is the container alone:With 3.8 million names the hash map is cache-miss bound on every reference lookup. The earlier judy-template comparison stands (lookups 1.2–7 ns vector, 8.5–21 ns
unordered_map, 27–101 ns judy). Left out because of the review concern about owning a container with policy in it; the numbers are here so that can be weighed.Verification
BUILD_IFCOPENSHELL_PARSE_TESTS=ON), including: serial vs 5-thread equality, paged serial and paged parallel equality, on a 12 MB replicated fixture; per-instanceto_string()equality, inverse counts and GlobalId lookups of a lazily opened file against the full parse on the fixture and on an inline file with mixed selects, nested lists and missing names; the lazy fallback on a stray keyword; the attribute scan's corner cases (doubled quotes, comments, nested lists, binaries, references without digits, a slash, a semicolon inside an instance, unterminated input); inline and overlong GlobalIds; scalar, list, nested, mixed, missing and bypassed references.test/apitree, plustest/test_lazy.py(equality, editing and writing a lazy file, fallback): 1907 passed, 31 skipped, with the default parallel parse. One existing test used a two-character GlobalId; it now uses a real one and checks that a short key is rejected.Not in this PR: making the paged reader the default for the strict parse (commit 10 is opt in), chunking the lazy index over threads, the Python-level
__setattr__SWIG goes through when creating each wrapper (0.19 µs of 0.8 µs per instance).🤖 Generated with Claude Code
https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL