Skip to content

[AI-generated, unverified] ifcparse: faster, smaller open(): one tokenizer, references in the slots, lazy loading, parallel and paged parsing - #9492

Open
Moult wants to merge 16 commits into
v0.9.0from
open-perf-v2
Open

Moult wants to merge 16 commits into
v0.9.0from
open-perf-v2

Conversation

@Moult

@Moult Moult commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

This PR was written entirely by an AI coding tool (Claude Code) and has not been verified by a human. The maintainer who opened it has not reviewed the code, the design, or the benchmark methodology.

This is the fresh series asked for in #9473: one tokenizer, minimal commits in the order (1) inlining and minimal optimisations, GlobalId map, heap allocation reduction; (2) template context on the existing tokenizer; (3) unresolved reference storage and lazy loading; (4) multithreading; plus the SWIG fast-proxy with validate_stub taught to read signatures from the compiled functions, two commits from profiling the lazy open, and the tokenizer restructured as scan(Consumer&) with next() as its one-token consumer (below). It replaces #9473, which stays open only as the reference for the numbers posted there. 14 commits; net +2427 −415 lines, of which +1950 −403 outside tests (#9473 added 2169). Two further commits, 15 and 16, are experiments on the serial phases and are assumed to be dropped unless wanted; they are described in their own section at the end and none of the numbers below include them. dense_id_map is not included. guid_map.h is gone as reviewed: the GlobalId index is the spelled-out std::unordered_map<std::array<char, 22>, ...> on the storage class.

Before and after

C++ file constructor on fourteen models: three public (TXG, 210_King, OKgate22) and eleven supplied privately for benchmarking, identified by size only. Wall clock on a 12-core Linux box, one run each; memory is anonymous RSS after the parse, peak is VmHWM; percentages are against v0.9.0 head (148ff02) in the same row. Every run logged zero parser messages.

model instances v0.9.0 head, 1 thread this PR, 1 thread this PR, 12 threads this PR, paged, 12 threads this PR, lazy, 1 thread this PR, lazy, 12 threads
TXG 58 MB 917,995 1.39 s, 357 MB (peak 463) 1.08 s (-22%), 274 MB (-23%), peak 365 (-21%) 0.44 s (-68%), peak 381 MB 0.46 s, peak 375 MB (-19%) 0.54 s, 147 MB (-59%) 0.37 s (-73%), 203 MB
210_King 147 MB 2,731,958 3.77 s, 877 MB (peak 1138) 2.73 s (-28%), 654 MB (-25%), peak 871 (-23%) 1.19 s (-68%), peak 903 MB 1.23 s, peak 808 MB (-29%) 1.57 s, 388 MB (-56%) 1.01 s (-73%), 449 MB
OKgate22 231 MB 3,825,882 6.21 s, 1364 MB (peak 1743) 5.06 s (-19%), 1036 MB (-24%), peak 1347 (-23%) 1.94 s (-69%), peak 1380 MB 1.98 s, peak 1201 MB (-31%) 2.07 s, 542 MB (-60%) 1.36 s (-78%), 627 MB
10 MB (private) 209,548 0.23 s, 61 MB (peak 90) 0.18 s (-22%), 47 MB (-23%), peak 75 (-17%) 0.08 s (-65%), peak 81 MB 0.09 s, peak 85 MB (-6%) 0.10 s, 33 MB (-46%) 0.07 s (-70%), 45 MB
74 MB (private) 1,162,030 1.62 s, 444 MB (peak 573) 1.26 s (-22%), 340 MB (-23%), peak 449 (-22%) 0.53 s (-67%), peak 485 MB 0.55 s, peak 462 MB (-19%) 0.60 s, 163 MB (-63%) 0.42 s (-74%), 222 MB
81 MB (private) 1,527,802 1.77 s, 443 MB (peak 584) 1.30 s (-27%), 333 MB (-25%), peak 451 (-23%) 0.52 s (-71%), peak 478 MB 0.56 s, peak 450 MB (-23%) 0.69 s, 194 MB (-56%) 0.45 s (-75%), 246 MB
107 MB (private) 2,152,533 2.42 s, 581 MB (peak 792) 1.74 s (-28%), 433 MB (-25%), peak 590 (-26%) 0.75 s (-69%), peak 608 MB 0.77 s, peak 552 MB (-30%) 1.03 s, 275 MB (-53%) 0.64 s (-74%), 339 MB
249 MB (private) 3,884,378 5.62 s, 1335 MB (peak 1752) 4.26 s (-24%), 1022 MB (-23%), peak 1378 (-21%) 1.92 s (-66%), peak 1438 MB 2.05 s, peak 1240 MB (-29%) 2.32 s, 551 MB (-59%) 1.61 s (-71%), 645 MB
279 MB (private) 625,577 4.81 s, 720 MB (peak 1044) 3.77 s (-22%), 669 MB (-7%), peak 974 (-7%) 1.13 s (-77%), peak 1005 MB 1.19 s, peak 779 MB (-25%) 1.61 s, 102 MB (-86%) 0.83 s (-83%), 163 MB
315 MB (private) 5,618,442 7.55 s, 1791 MB (peak 2333) 5.59 s (-26%), 1346 MB (-25%), peak 1777 (-24%) 2.24 s (-70%), peak 1852 MB 2.29 s, peak 1586 MB (-32%) 2.88 s, 730 MB (-59%) 1.86 s (-75%), 833 MB
349 MB (private) 6,430,306 7.79 s, 1913 MB (peak 2551) 5.69 s (-27%), 1441 MB (-25%), peak 1913 (-25%) 2.41 s (-69%), peak 1967 MB 2.52 s, peak 1670 MB (-35%) 3.18 s, 816 MB (-57%) 2.02 s (-74%), 910 MB
523 MB (private) 1,056,071 8.91 s, 1320 MB (peak 1913) 6.94 s (-22%), 1232 MB (-7%), peak 1790 (-6%) 2.11 s (-76%), peak 1825 MB 2.23 s, peak 1354 MB (-29%) 2.99 s, 170 MB (-87%) 1.51 s (-83%), 233 MB
603 MB (private) 6,579,063 10.97 s, 2289 MB (peak 3081) 8.55 s (-22%), 1889 MB (-17%), peak 2598 (-16%) 3.23 s (-71%), peak 2649 MB 3.35 s, peak 2098 MB (-32%) 4.38 s, 851 MB (-63%) 2.61 s (-76%), 972 MB
787 MB (private) 16,232,582 17.20 s, 4304 MB (peak 5811) 12.48 s (-27%), 3174 MB (-26%), peak 4255 (-27%) 5.62 s (-67%), peak 4384 MB 5.76 s, peak 3649 MB (-37%) 7.28 s, 2028 MB (-53%) 4.85 s (-72%), 2161 MB

Python ifcopenshell.open on the same fourteen models, wall clock and RSS growth over the bare interpreter as a multiple of file size. This PR'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.

model v0.8.5 v0.9.0 head this PR, default this PR, lazy
TXG 58 MB 2.81 s, 10.6x file size 1.45 s, 6.2x file size 0.44 s, 4.9x file size (-70%) 0.37 s, 3.6x file size (-74%)
210_King 147 MB 7.14 s, 11.1x file size 3.87 s, 6.0x file size 1.19 s, 4.5x file size (-69%) 1.01 s, 3.1x file size (-74%)
OKgate22 231 MB 11.44 s, 10.4x file size 6.46 s, 5.9x file size 1.91 s, 4.5x file size (-70%) 1.36 s, 2.7x file size (-79%)
10 MB (private) 0.50 s, 12.6x file size 0.25 s, 6.5x file size 0.09 s, 5.4x file size (-64%) 0.08 s, 4.7x file size (-68%)
74 MB (private) 3.56 s, 10.6x file size 1.68 s, 6.1x file size 0.50 s, 5.0x file size (-70%) 0.40 s, 3.1x file size (-76%)
81 MB (private) 3.57 s, 10.4x file size 1.82 s, 5.5x file size 0.53 s, 4.4x file size (-71%) 0.45 s, 3.1x file size (-75%)
107 MB (private) 5.16 s, 11.4x file size 2.49 s, 5.5x file size 0.74 s, 4.1x file size (-70%) 0.63 s, 3.2x file size (-75%)
249 MB (private) 11.93 s, 9.8x file size 5.78 s, 5.4x file size 1.91 s, 4.2x file size (-67%) 1.60 s, 2.6x file size (-72%)
279 MB (private) 9.65 s, 3.2x file size 5.13 s, 2.6x file size 1.14 s, 2.5x file size (-78%) 0.83 s, 0.6x file size (-84%)
315 MB (private) 14.74 s, 10.8x file size 7.82 s, 5.8x file size 2.25 s, 4.4x file size (-71%) 1.82 s, 2.7x file size (-77%)
349 MB (private) 16.52 s, 11.1x file size 8.37 s, 5.5x file size 2.43 s, 4.2x file size (-71%) 2.01 s, 2.6x file size (-76%)
523 MB (private) 18.01 s, 3.1x file size 9.18 s, 2.5x file size 2.13 s, 2.4x file size (-77%) 1.53 s, 0.4x file size (-83%)
603 MB (private) 22.52 s, 6.5x file size 11.37 s, 3.9x file size 3.28 s, 3.2x file size (-71%) 2.64 s, 1.6x file size (-77%)
787 MB (private) 36.95 s, 11.5x file size 18.12 s, 5.5x file size 5.70 s, 4.0x file size (-69%) 4.88 s, 2.8x file size (-73%)

The commits

Lines are per commit, added and removed, with the tests counted separately so the cost of each item is visible.

# commit code +/− tests +/− what, and which item of the requested order
1 e0b6122 +51 −62 +0 −0 (1) minimal optimisations: the reader accessors inline, the inverse vector pre-sized (was #9474)
2 64805b4 +94 −14 +33 −12 (1) the GlobalId index as std::unordered_map<std::array<char, 22>, ...> on the storage, with variant_map converting from std::string at the file interface; only a 22-character GlobalId can be stored or found
3 d0ac888 +35 −28 +0 −0 (1) heap allocation reduction: one block per attribute array, std::optional storage
4 d647050 +64 −0 +0 −0 (1) the paged reader caches the page its cursor is on, revalidated by an eviction counter
5 a026fc6 +49 −18 +0 −0 (1) the SWAR helpers, the reader's cursor accessors, paged size() and the page-cache check forced inline; found with callgrind
6 9829ebf +102 −9 +48 −0 (2) spf_lexer::next<Policy>(): full_tokens is the parser, index_tokens ends strings without decoding them and passes over numbers, enumerations and binaries; character_decoder::skip() is the same state machine with collection compiled out; also fixes a comment that follows a token without whitespace
7 91622b9 +148 −16 +92 −0 (3) references stay in the slot as the existing instance_reference / reference_or_simple_type aggregate until every instance is read, mixed lists included; no side table for the parse
8 a92bebd +352 −7 +154 −0 (3) lazy loading through next<index_tokens>(); offsets in one lazy-only sorted vector, nothing added to instance_data for the full parse
9 b1cba75 +499 −91 +91 −3 (4) parallel parsing; the instance-header loop is shared with the lazy index; the split-point scan tracks strings and comments
10 43686bd +22 −2 +19 −1 (4) opt-in full parse through the paged reader
11 1d95036 +110 −3 +0 −0 SWIG -fastproxy -fastdispatch; validate_stub rebuilds signatures from the compiled functions' docstrings
12 97c83f0 +27 −6 +0 −0 (profile) keyword, enumeration and binary text inside an attribute list not copied by the index
13 f24e9a6 +242 −161 +8 −1 (profile) the lazy index chunked over the same split points as the parallel parse, merged in file order; serial is the one-chunk case
14 17ec5d4 +519 −350 +37 −0 (consumer model) the tokenizer body as scan(Consumer&) in spf_scan.h, callbacks inlining into the loop; next() kept as the one-token consumer for the pull parser; the lazy index is a consumer
15 aaeeecb +46 −1 +0 −0 (experiment) inverse_index::sort_records(): LSD radix sort on referenced_id, record_less within each run of equal ids, same order as before
16 788d257 +112 −28 +0 −0 (experiment) each parse and index worker sorts its own records, builds its own by-type lists and reads its own GlobalIds; the main thread merges sorted runs (inverse_index::merge_sorted)

Each commit message carries its own numbers on the three public models.

One tokenizer

There is no byte-level scanner left. The lazy index consumes next<index_tokens>() and counts parentheses and commas on the operator tokens; the parallel chunker and the lazy index share one instance-header loop, for_each_instance_header(), which looks declarations up once per keyword, passes over a bypassed instance's attribute list and slides past a stray keyword the way the serial reader does. Under both policies a string ends at the byte the decoder's state machine says it ends at; the equivalence test includes '', \X2\ and \S\', an apostrophe used as the page character, which any quote-counting byte scan (including the one in #9473) ended early.

The one place that looks at raw bytes is the split-point scan for parallel parsing, because tokenizing the file serially to find the split points would leave nothing to parallelise. It applies three rules: a string starts and ends at a quote and cannot span a line, and a comment runs from /* to */; a split is a # that starts a line outside both. Getting a string's end wrong can only lose a candidate, never accept a wrong one, since no string contains a newline. The equality test puts a comment holding a fake instance and a string holding /* between the chunks. Comments in the DATA section therefore no longer force the serial path.

Throughput of the stages

Single thread, whole file. The index policy is the tokenizer with strings ended but not decoded and values passed over; the paged columns read through 64 KB pages with a 4 MB cache.

model tokenizer, in memory tokenizer, paged index policy through next(), in memory scan() consumer, in memory scan() consumer, paged full parse, 1 thread paged parse, 1 thread lazy open + one attribute of every instance
TXG 58 MB 206 MB/s 216 MB/s 242 MB/s 337 MB/s 265 MB/s 54 MB/s 1.09 s (+1%) 0.54 + 0.56 s
210_King 147 MB 186 MB/s 189 MB/s 207 MB/s 269 MB/s 214 MB/s 54 MB/s 2.78 s (+2%) 1.57 + 1.34 s
OKgate22 231 MB 198 MB/s 207 MB/s 231 MB/s 315 MB/s 246 MB/s 46 MB/s 5.09 s (+1%) 2.07 + 2.35 s
10 MB (private) 194 MB/s 196 MB/s 216 MB/s 299 MB/s 227 MB/s 56 MB/s 0.18 s (+0%) 0.10 + 0.10 s
74 MB (private) 205 MB/s 219 MB/s 245 MB/s 343 MB/s 261 MB/s 59 MB/s 1.26 s (+0%) 0.60 + 0.72 s
81 MB (private) 198 MB/s 201 MB/s 221 MB/s 298 MB/s 239 MB/s 62 MB/s 1.33 s (+2%) 0.69 + 0.69 s
107 MB (private) 186 MB/s 190 MB/s 206 MB/s 276 MB/s 220 MB/s 61 MB/s 1.76 s (+1%) 1.03 + 0.96 s
249 MB (private) 201 MB/s 204 MB/s 228 MB/s 295 MB/s 240 MB/s 58 MB/s 4.33 s (+2%) 2.32 + 2.24 s
279 MB (private) 153 MB/s 161 MB/s 182 MB/s 266 MB/s 211 MB/s 74 MB/s 3.89 s (+3%) 1.61 + 3.49 s
315 MB (private) 196 MB/s 204 MB/s 223 MB/s 297 MB/s 237 MB/s 56 MB/s 5.69 s (+2%) 2.88 + 3.04 s
349 MB (private) 191 MB/s 193 MB/s 214 MB/s 287 MB/s 229 MB/s 61 MB/s 5.88 s (+3%) 3.18 + 3.16 s
523 MB (private) 156 MB/s 164 MB/s 184 MB/s 275 MB/s 214 MB/s 75 MB/s 7.18 s (+3%) 2.99 + 6.49 s
603 MB (private) 185 MB/s 191 MB/s 210 MB/s 299 MB/s 239 MB/s 71 MB/s 8.56 s (+0%) 4.38 + 5.26 s
787 MB (private) 205 MB/s 215 MB/s 228 MB/s 309 MB/s 248 MB/s 63 MB/s 12.74 s (+2%) 7.28 + 6.73 s

Reading across: the index policy is 20–35% faster than the full tokenizer; through pages both run at 90–105% of their in-memory speed; the full parse through pages costs a few percent on one thread and is level at 12 threads, with peak memory down by the size of the file. A lazy open on one thread is 50–60% of the strict single-thread parse and holds 130–160 bytes per instance; on 12 threads it is under the default (parallel full) open on every model. Reading every attribute afterwards costs about the strict parse again, so lazy is for touching a fraction of the model.

What lazy loading is

One pass over the DATA section through the tokenizer's index policy builds everything that indexes the file: a shell per instance (name and declaration, no attribute array), the complete inverse index with attribute indices, the GlobalId map and the by-type lists. No attribute value is decoded. The first time anything touches an instance's attributes, ensure_loaded() seeks the retained paged reader to the instance and runs the same load_attributes() the full parse runs, with inverse registration off, then resolves that instance's references from its own slots. A modified instance is materialised first, so writing works. The offset of each instance's attribute list lives in one sorted vector that exists only in lazy mode. Materialising from several threads at once is not safe.

Where the lazy open's time went, and what was done about it

Callgrind on the lazy open of TXG, on this branch against the byte scanner of #9473: the index pass is 0.28 s through next<index_tokens>() against 0.055 s for the byte scan, and the rest of a lazy open (shells, inverse sort, GlobalIds) is the same 0.33 s in both. The samples in the tokenizer: 37% in the index-policy next() itself, 6.7% in file_reader::peek() as a non-inlined call per byte from the whitespace skip, about 5% copying keyword and enumeration text the index never reads, 2% parsing names. The byte scanner did none of that: seven characters through one SWAR mask. Roughly 17 ns per token times 13 million tokens is the gap. Three hypotheses, each measured on the three public models (TXG / 210_King / OKgate22):

change index tokenizer, TXG lazy open, 1 thread lazy open, 12 threads
before 236 MB/s 0.61 / 1.73 / 2.86 s (serial)
(a) reader cursor accessors forced inline (folded into commit 5; the full parse gains ~2% too) 261 MB/s 0.58 / 1.73 / 2.86 s
(b) attribute-list policy: keyword, enumeration and binary text not copied (commit 12) 293 MB/s 0.57 / 1.65 / 2.78 s
(c) the index chunked over threads, name table reserved before the merge (commit 13) 0.55 / 1.59 / 2.11 s 0.37 / 1.01 / 1.34 s
for comparison: the default (parallel full) open 0.44 / 1.17 / 1.91 s

(a) and (b) recover about a fifth of the tokenizer gap: the per-token call is the cost, not what the token carries (measured per token type on synthetic files: 9–16 ns per token whatever the token is, strings the cheapest). (c) is what puts lazy back under the default open. Skipping non-structural tokens inside next() was also tried and reverted: on real files the structural tokens and names are the majority, so it changed nothing measurable.

Commit 14 then inverts the tokenizer as asked: the body of next() becomes scan(Consumer&) in spf_scan.h, handing each token to the consumer's callbacks (which inline into the loop) instead of building a token; next<Policy>() is kept as the consumer that stops after one token, so the recursive pull parser is untouched; the lazy index is attribute_consumer. Index pass, nothing decoded, in memory: 214–247 MB/s through next() → 266–343 MB/s through scan() (the byte scanner it replaces: 700–1300 MB/s); lazy open on one thread TXG / King / OKgate22 0.55 / 1.59 / 2.11 → 0.54 / 1.57 / 2.07 s. The full tokenizer through the adapter is unchanged. End to end this commit is an architecture change, not a performance one: on the fourteen models the lazy open moved by −1% (1 thread) and +2% (12 threads) on average, within run-to-run noise, the strict parse and memory not at all; the tokenizer pass is a small share of a lazy open and the strict parse still pulls tokens through the adapter.

SWIG fast-proxy

-fastproxy binds each wrapped method directly to its compiled function instead of a generated Python def; -fastdispatch shortens overload dispatch. Reading is_a(), id() and an attribute of every one of TXG's 918k instances: 2.36 s → 2.15 s (−8%). validate_stub compared the stub against the wrapper's def signatures; a fast-proxy wrapper has assignments, so it now rebuilds each signature from the compiled function's autodoc docstring with the rule SWIG itself uses (one prototype with literal defaults → named parameters; overloads or a non-literal default such as an enum → *args), checked against every def of a non-fast-proxy wrapper: 773 of 773 identical. The autodoc feature moves above the SWIG library includes so the iterator and container classes carry prototypes too.

Experiments on top: commits 15 and 16

Kept at the end of the branch so they are easy to drop. After commit 14 the tokenizer pass parallelises, and what is left of a 12-thread open is the serial merge: the inverse-record sort, the by-type lists, the name and GlobalId tables. Commit 15 replaces the introsort of the inverse records by a radix sort with the same resulting order; commit 16 moves the sort, the by-type lists and the GlobalId decode into the workers and merges sorted runs. Single-threaded, 16 is neutral by construction and 15 is the sort alone. Same fourteen models, same bench, commit 14 → commit 16 (the tables above stay as measured at commit 14):

model strict, 1 thread strict, 12 threads lazy, 1 thread lazy, 12 threads peak memory, strict 12 threads Python default open Python lazy open
TXG 58 MB 1.08 → 0.99 s (-8%) 0.44 → 0.37 s (-16%) 0.54 → 0.47 s (-13%) 0.37 → 0.28 s (-24%) 381 → 392 MB 0.44 → 0.37 s (-16%) 0.37 → 0.28 s (-24%)
210_King 147 MB 2.73 → 2.50 s (-8%) 1.19 → 0.90 s (-24%) 1.57 → 1.35 s (-14%) 1.01 → 0.75 s (-26%) 903 → 933 MB 1.19 → 0.90 s (-24%) 1.01 → 0.76 s (-25%)
OKgate22 231 MB 5.06 → 4.78 s (-6%) 1.94 → 1.40 s (-28%) 2.07 → 1.91 s (-8%) 1.36 → 1.09 s (-20%) 1380 → 1434 MB 1.93 → 1.42 s (-26%) 1.36 → 1.11 s (-18%)
10 MB (private) 0.18 → 0.15 s (-17%) 0.08 → 0.06 s (-25%) 0.10 → 0.09 s (-10%) 0.07 → 0.06 s (-14%) 81 → 78 MB 0.09 → 0.07 s (-22%) 0.08 → 0.06 s (-25%)
74 MB (private) 1.26 → 1.13 s (-10%) 0.53 → 0.41 s (-23%) 0.60 → 0.56 s (-7%) 0.42 → 0.32 s (-24%) 485 → 479 MB 0.50 → 0.43 s (-14%) 0.40 → 0.33 s (-18%)
81 MB (private) 1.30 → 1.22 s (-6%) 0.52 → 0.42 s (-19%) 0.69 → 0.63 s (-9%) 0.45 → 0.37 s (-18%) 478 → 470 MB 0.55 → 0.44 s (-20%) 0.45 → 0.37 s (-18%)
107 MB (private) 1.74 → 1.60 s (-8%) 0.75 → 0.57 s (-24%) 1.03 → 0.91 s (-12%) 0.64 → 0.49 s (-23%) 608 → 622 MB 0.74 → 0.57 s (-23%) 0.63 → 0.50 s (-21%)
249 MB (private) 4.26 → 3.86 s (-9%) 1.92 → 1.42 s (-26%) 2.32 → 2.01 s (-13%) 1.61 → 1.15 s (-29%) 1438 → 1478 MB 1.91 → 1.45 s (-24%) 1.60 → 1.16 s (-28%)
279 MB (private) 3.77 → 3.71 s (-2%) 1.13 → 1.04 s (-8%) 1.61 → 1.57 s (-2%) 0.83 → 0.77 s (-7%) 1005 → 1015 MB 1.13 → 1.06 s (-6%) 0.83 → 0.79 s (-5%)
315 MB (private) 5.59 → 5.12 s (-8%) 2.24 → 1.70 s (-24%) 2.88 → 2.47 s (-14%) 1.86 → 1.37 s (-26%) 1852 → 1915 MB 2.24 → 1.74 s (-22%) 1.82 → 1.40 s (-23%)
349 MB (private) 5.69 → 5.30 s (-7%) 2.41 → 1.82 s (-24%) 3.18 → 2.81 s (-12%) 2.02 → 1.52 s (-25%) 1967 → 1979 MB 2.44 → 1.81 s (-26%) 2.01 → 1.54 s (-23%)
523 MB (private) 6.94 → 6.91 s (-0%) 2.11 → 2.04 s (-3%) 2.99 → 2.87 s (-4%) 1.51 → 1.40 s (-7%) 1825 → 1851 MB 2.14 → 1.99 s (-7%) 1.53 → 1.43 s (-7%)
603 MB (private) 8.55 → 7.89 s (-8%) 3.23 → 2.67 s (-17%) 4.38 → 3.99 s (-9%) 2.61 → 2.12 s (-19%) 2649 → 2710 MB 3.29 → 2.68 s (-19%) 2.64 → 2.14 s (-19%)
787 MB (private) 12.48 → 11.27 s (-10%) 5.62 → 4.14 s (-26%) 7.28 → 6.18 s (-15%) 4.85 → 3.55 s (-27%) 4384 → 4454 MB 5.69 → 4.18 s (-27%) 4.88 → 3.55 s (-27%)

Single-threaded nothing changes. At 12 threads the peak is one transient copy of the records higher, for the merge: +1–4% strict, +0–10% lazy (the 787 MB model: 2684 → 2863 MB). Resident memory after the open is within ±2% once the allocator's free pages are trimmed, and 0–7% higher before trimming, which is glibc keeping the freed per-worker runs on the worker arenas (MALLOC_ARENA_MAX=1 removes the difference). A first form of commit 16 ran the radix sort inside the workers; its buffers stayed on those arenas and resident memory after a 12-thread open was 3–10% higher, so the workers now sort their runs in place with std::sort, which costs nothing at 12 threads (the runs are a twelfth of the records each) and keeps the radix sort for the single-threaded paths. A third experiment, deferring the lazy GlobalId map to first use, gained 0–8% on the 12-thread lazy open only and is not included.

Verification

  • 16 Catch2 parse cases (BUILD_IFCOPENSHELL_PARSE_TESTS=ON): token-for-token equivalence of the two policies including the escapes above; references resolved in place for scalars, lists, nested lists, mixed selects, missing and bypassed names; a lazily opened file against the full parse, per-instance to_string(), inverse counts and GlobalId lookups, on the fixture and on an inline file, plus the stray-keyword and fallback cases; serial, 5-thread, paged and paged 5-thread equality on a 12 MB replicated fixture with comments and /*-strings between the chunks.
  • Python: file, inverse, entity, gc, parse, open, stream, guid, global-id, validate, the stub validator, the whole test/api tree and the new test/test_lazy.py: 2063 passed, 31 skipped, with the default parallel parse and the fast-proxy wrapper; black and ruff clean. Four existing tests looked walls up by GlobalIds such as "id1"; they now use real 22-character ids, and test_file.py additionally checks that a short key is rejected.
  • Not tested: RocksDB backend (two ensure_loaded() calls added in the serializer, paths otherwise untouched), Windows/macOS, Bonsai (fast-proxy changes what the wrapper's methods are at the Python level: assignments instead of defs, so anything that introspects them the way validate_stub did would need the same change). The Python wrapper must be rebuilt against the library.

🤖 Generated with Claude Code

https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL

Comment thread src/ifcparse/guid_map.h Outdated
std::memcpy(key.data(), s.data(), key.size());
return key;
}
typedef std::unordered_map<guid_key, V, guid_key_hash> map_type;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer if we just make this the type in the ifcopenshell::file/storage class and remove this file. I find that more illustrative of what we do and its limitations (std::array<char, 22>) than introducing a seemingly opaque type that also might hint at a numeric map instead of a textual map.

@aothms

aothms commented Sep 14, 2026

Copy link
Copy Markdown
Member

Thanks for your patience I think with the last comments addressed we're nearly at a place where important logic is reused rather than reintroduced :)

@aothms

aothms commented Sep 14, 2026

Copy link
Copy Markdown
Member

It does seem that lazy got quite a bit slower, slower than non-lazy?

model v0.8.5 v0.9.0 head this PR, default this PR, lazy
349 MB (private) 16.52 s, 11.1x file size 8.37 s, 5.5x file size 2.40 s, 4.2x file size (-71%) 3.51 s, 2.4x file size (-58%)
523 MB (private) 18.01 s, 3.1x file size 9.18 s, 2.5x file size 2.13 s, 2.4x file size (-77%) 3.91 s, 0.3x file size (-57%)
603 MB (private) 22.52 s, 6.5x file size 11.37 s, 3.9x file size 3.30 s, 3.2x file size (-71%) 5.14 s, 1.4x file size (-55%)
787 MB (private) 36.95 s, 11.5x file size 18.12 s, 5.5x file size 5.70 s, 4.0x file size (-69%) 8.14 s, 2.7x file size (-55%)

once we have the PR in a state of good human comprehension let's hypothesise what we can do optimising with a profiler and really see where the allocation of samples is different wrt to the fully inlined scan blob from the other PR.

Moult and others added 13 commits September 14, 2026 19:33
…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
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
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
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
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
spf_lexer::next() becomes next<Policy>(). full_tokens, the default, is
what the parser has always had. index_tokens is what the lazy index
needs: a string is ended but not decoded, and a number, enumeration or
binary comes back as Token_LITERAL with only its position; names,
keywords and operators are read as before. Each policy compiles to its
own loop from the one implementation, so there is no second tokenizer.

character_decoder gains skip(): the same state machine as the
conversion with the collection compiled out, so an escape such as \S\'
(an apostrophe as the page character) ends the string at the same byte
under both policies. A byte-level scan would have ended it early.

Also fixes a comment that follows a token without whitespace, ",/* x */",
which skip_comment() never saw because the slash had been consumed.

TXG (58 MB), single thread: tokenizing the whole file 194 MB/s with
full_tokens, 249 MB/s with index_tokens; through 64 KB pages 196 and
205 MB/s. The parse itself is unchanged.

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 parser could not resolve a #name when it read it, because the
instance may be defined further down the file, so it left the slot empty
and appended (owner, attribute, name) to a side table that a second pass
walked. The table held one entry per reference for the whole read: 64 MB
on a 58 MB model, the high-water mark of opening.

Now the reference stays where the tokenizer put it: the attribute slot
holds the instance_reference, or the reference_or_simple_type aggregate
for a list (mixed with inline typed values or not), until every instance
has been read, and resolve_instance_references() walks each instance's
slots and swaps names for instances. Ordering is what the tokenizer
produced; nothing is re-derived. A missing name becomes null in a scalar
and is dropped from an aggregate, as before; the error keeps its offset.
The three transient alternatives are appended to the attribute pack and
to argument_type in lock step and are never visible once a file is
loaded. Simple type instances read inline (IfcPropertySetDefinitionSet)
have their own slots, so their references need no diversion.

The table remains for the header entities and for streaming consumers of
instance_streamer::references(), which leave resolve_references_in_place
off.

TXG 58 MB / 210_King 147 MB / OKgate22 231 MB, single thread: time
unchanged (1.05 / 2.69 / 4.99 s), memory after the parse 287 -> 274,
698 -> 654, 1086 -> 1036 MB, peak 400 -> 365, 965 -> 871, 1471 -> 1347 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
…True)

A lazy open reads the DATA section once with the tokenizer's index
policy and builds what indexes the file: a shell per instance (name and
declaration, no attribute array), the complete inverse index with
attribute indices, the GlobalId map and the by-type lists. No attribute
value is decoded. The first time an instance's attributes are touched,
ensure_loaded() seeks the retained paged reader to the instance and runs
the same load_attributes() the full parse runs, with inverse registration
off, then resolves that instance's references from its own slots. A
modified instance is materialised first, so writing works.

There is no scanner of its own: the index pass consumes next<index_tokens>()
and counts parentheses and commas on the operator tokens; a keyword where
an instance should start, or a token the tokenizer rejects, stops the
index and the file is parsed in full. The offset of each instance's
attribute list is kept in one sorted vector that exists only in lazy
mode, so a full parse pays nothing for it. Materialising from several
threads at once is not safe.

TXG 58 MB / 210_King 147 MB / OKgate22 231 MB, single thread: lazy open
0.61 / 1.73 / 2.86 s against the full parse's 1.05 / 2.69 / 4.99 s, at
141 / 374 / 534 MB against 274 / 654 / 1036 MB; reading one attribute of
every instance afterwards costs a further 0.56 / 1.44 / 4.86 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
The DATA section is split into one chunk per thread and each worker runs
the same per-instance reader as the serial parse over its own reader,
storage, inverse records and simple-type list; the results are merged in
file order, so instance order, GlobalId precedence and inverse records
are identical to the serial parse. Reference resolution then splits over
the same threads: each instance's slots are its own and the name table
is complete and read-only by then. The default is one thread per core,
capped at 16; IFCOPENSHELL_PARSE_THREADS or file::parse_threads()
overrides it, and 1 parses as before.

The instance headers are read by one loop, for_each_instance_header(),
shared with the lazy index: it looks declarations up once per keyword,
passes over a bypassed instance's attribute list and slides past a stray
keyword the way the serial reader does (the lazy index therefore no
longer falls back on one).

Finding the split points is the one place that looks at raw bytes rather
than tokens, because tokenizing the file serially first would leave
nothing to parallelise. It applies three rules: a string starts and ends
at a quote and cannot span a line, and a comment runs from /* to */; a
split is a '#' that starts a line outside both. Getting a string's end
wrong can only lose a candidate, never accept a wrong one, since no
string contains a newline. The equality test puts a comment holding a
fake instance and a string holding "/*" between the chunks.

file_reader gains for_each_span(), which hands a byte range out span by
span (one span for a buffer, one per page for the paged reader), and
reopen(), a reader over the same file for another thread.

TXG 58 MB / 210_King 147 MB / OKgate22 231 MB, 12 threads: 0.44 / 1.24 /
2.01 s against 1.07 / 2.75 / 5.12 s on one thread; memory after the parse
within 1–4%, peak +5–4%.

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), set before initialize(), runs the full parse
(serial or parallel) through the paged reader with 64 KB pages and a
4 MB cache instead of reading the whole file into memory; the whole
file is then never held. Every stage already reads through the reader,
so nothing else changes. The equality test now runs the same file paged,
serially and with five workers each holding its own page cache.

TXG 58 MB / 210_King 147 MB / OKgate22 231 MB: one thread 1.09 / 2.89 /
5.25 s against 1.07 / 2.75 / 5.12 s in memory, twelve threads 0.48 /
1.27 / 1.99 s against 0.44 / 1.24 / 2.01 s; peak memory 311 / 727 /
1119 MB against 365 / 871 / 1347 MB, that is, down by the size of the
file. Whether this should become the default is a decision the numbers
on the PR are meant to inform.

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
-fastproxy binds each wrapped method directly to its compiled function
instead of going through a generated Python def, and -fastdispatch
shortens overload dispatch. Reading is_a(), id() and an attribute of
every one of TXG's 918k instances takes 2.15 s instead of 2.36 s (-8%);
the tight loop over 133 walls reading GlobalId, Name and is_a() twenty
times over 9 ms instead of 11 ms.

validate_stub compared the stub against the wrapper's def signatures by
parsing both files; a fast-proxy wrapper has assignments instead of
defs. It now rebuilds the signature from the compiled function's
autodoc docstring (the C++ prototype) with the rule SWIG itself uses:
one prototype whose defaults are Python literals becomes named
parameters, several prototypes or a non-literal default such as an enum
become *args. Checked against every def of a wrapper built without
-fastproxy: 773 of 773 signatures rebuild identically. The autodoc
feature moves above the SWIG library includes so the iterator and
container classes carry prototypes too.

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
…ying it

Inside an attribute list the index looks only at operators and names,
so a third tokenizer policy, attribute_tokens, returns a keyword (an
inline typed value such as IFCLABEL), an enumeration or a binary as
Token_LITERAL without copying its text; only a name's digits are kept.
The instance headers still go through index_tokens, which keeps the
keyword. Same next(), one more compile-time branch.

Found by callgrind on the lazy open (see the PR): string-pool access and
keyword text copying for tokens the index never read. Whole-file
tokenizing of TXG (58 MB) with the index policy 265 MB/s -> 293 MB/s;
lazy open TXG / 210_King / OKgate22 0.58 / 1.73 / 2.86 s -> 0.57 / 1.65 /
2.78 s. Small: the per-token call is the larger cost, which the next
commit addresses by splitting the work over threads.

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
On a file large enough the DATA section is split at the same boundaries
the parallel parse uses (chunk_bounds(), now shared) and each chunk is
indexed by its own worker with its own paged reader, lexer, shells,
offsets, GlobalIds and inverse records; the results are merged in file
order, so instance order, GlobalId precedence and inverse records are
identical to the serial index. The serial index is the same code run on
one chunk. The name table is reserved before the merge, which also
helps the serial case. The default thread count is the one the full
parse uses.

TXG 58 MB / 210_King 147 MB / OKgate22 231 MB, lazy open: 12 threads
0.37 / 1.01 / 1.34 s against 0.55 / 1.59 / 2.11 s on one thread and
0.44 / 1.17 / 1.91 s for the default (parallel full) open; memory after
the open +15 to +50 MB at 12 threads for the workers' page caches. The
equality test now opens the 12 MB replicated fixture lazily with five
workers and compares it instance by instance with the serial parse.

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
@Moult

Moult commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

(AI-written, not verified by Dion.)

guid_map.h is gone. The GlobalId index is now the spelled-out std::unordered_map<std::array<char, 22>, express::base, guid_key_hash> on the storage class, with a two-line guid_key() next to it that says which strings qualify; variant_map converts from std::string at the file's interface (and a string of the wrong length is simply never found or stored). Folded into commit 2, which is now +94 −14 outside tests.

Lazy slower than non-lazy. Two things were mixed in that table: the default column is the 12-thread parallel parse and the lazy open was single-threaded; and lazy did get slower than #9473 by exactly the difference between the byte scanner and the tokenizer. I profiled it as you suggested before changing anything. Callgrind on the lazy open of TXG, this branch against the byte scanner: the index pass is 0.28 s through next<index_tokens>() against 0.055 s for the byte scan, and the rest of a lazy open (shells, inverse sort, GlobalIds) is the same 0.33 s in both. Inside the tokenizer the samples were 37% in the index-policy next() itself, 6.7% in a non-inlined file_reader::peek() called per byte from the whitespace skip, about 5% copying keyword and enumeration text the index never reads, 2% parsing names; the byte scanner did none of that, it looked at seven characters through one SWAR mask. About 17 ns per token times 13 million tokens is the gap. Three hypotheses, each measured (TXG / 210_King / OKgate22, lazy open on one thread):

change index tokenizer, TXG lazy open, 1 thread lazy open, 12 threads
before 236 MB/s 0.61 / 1.73 / 2.86 s
(a) the reader's cursor accessors forced inline, folded into commit 5; the full parse gains ~2% too 261 MB/s 0.58 / 1.73 / 2.86 s
(b) a third policy for the inside of an attribute list: keyword, enumeration and binary text not copied (commit 12) 293 MB/s 0.57 / 1.65 / 2.78 s
(c) the index chunked over the same split points as the parallel parse, name table reserved before the merge (commit 13) 0.55 / 1.59 / 2.11 s 0.37 / 1.01 / 1.34 s
the default (parallel full) open, for comparison 0.44 / 1.17 / 1.91 s

(a) and (b) recover about a fifth of the tokenizer gap: the per-token call is the cost, not what the token carries, and while next() returns a token per call it will not reach the byte scanner's 1 GB/s. (c) is what puts lazy back under the default. The remaining single-thread lever, not tried, is the consumer-driven next() you mentioned earlier: hand tokens to a callable so the index's loop can inline into the tokenizer.

All fourteen models, this branch, C++ (memory after the open):

model default open (parallel full) lazy, 1 thread lazy, 12 threads
TXG 58 MB 0.43 s 0.53 s, 147 MB 0.36 s, 203 MB
210_King 147 MB 1.16 s 1.56 s, 388 MB 0.99 s, 453 MB
OKgate22 231 MB 1.89 s 2.10 s, 542 MB 1.34 s, 622 MB
10 MB (private) 0.08 s 0.10 s, 33 MB 0.07 s, 45 MB
74 MB (private) 0.49 s 0.61 s, 163 MB 0.38 s, 223 MB
81 MB (private) 0.51 s 0.69 s, 194 MB 0.44 s, 250 MB
107 MB (private) 0.72 s 1.02 s, 275 MB 0.63 s, 339 MB
249 MB (private) 1.95 s 2.35 s, 551 MB 1.58 s, 645 MB
279 MB (private) 1.09 s 1.72 s, 102 MB 0.82 s, 164 MB
315 MB (private) 2.20 s 2.86 s, 730 MB 1.82 s, 842 MB
349 MB (private) 2.41 s 3.19 s, 816 MB 1.99 s, 920 MB
523 MB (private) 2.08 s 3.15 s, 170 MB 1.53 s, 233 MB
603 MB (private) 3.31 s 4.53 s, 851 MB 2.63 s, 973 MB
787 MB (private) 5.75 s 7.43 s, 2028 MB 4.89 s, 2191 MB

From Python, lazy is now 4–11% faster than the default open on every model at 55–70% of its memory. Full tables, the per-commit code and test line counts, and the profile numbers are in the PR body; 13 commits, net +1982 −176 lines (+1542 −164 outside tests).

@aothms

aothms commented Sep 14, 2026

Copy link
Copy Markdown
Member

(a) and (b) recover about a fifth of the tokenizer gap: the per-token call is the cost, not what the token carries, and while next() returns a token per call it will not reach the byte scanner's 1 GB/s.

What I think would be interesting to benchmark is on what token types the difference is most pronounced. We can also skip converting the reals to float for example on the lazy path (if not already). But it could well be that a major driver for the performance difference is the string handling, for cases such as (as you noted):

a quote-counting byte scan ends a string early at \S', which the shared decoder does not

So maybe benchmark the difference on files with:

  • mixed content
  • string performance (e.g a large amount of IfcPerson instances with interesting names - also large amounts of empty strings)
  • real performance (e.g a large amount of IfcCartesianPointList3D)
  • handling of aggregates (IfcPolyline?)
  • nested types performance (IfcTrimmedCurve?)

Because profiling otherwise averages everything out. This will indicate more clearly where the difference exists.

As I said I have a suspicion its the strings and the character decoder that kicks in to determine the string literal - in case of complex encoding, which is often not there. Maybe we can SWAR or otherwise handle this more efficiently. Is see you already added back character_decoder::skip() (which we previously had) which is a step in the right direction.


I don't know if the consumer model matches the structure well, but otherwise maybe a lexer::next_n(vector&) call can also be investigated - or maybe even just exhaust tokenize one page at a time.

Or maybe omit tokens in next() that we don't care about for lazy. Because you can keep the structural ones such as ( ) , and just skip over all the non-identifier tokens within next(). , will increment attribute count regardless of whether we saw an attribute value.

@Moult

Moult commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

(AI-written, not verified by Dion.)

Measured per token type as you suggested, on 50 MB synthetic files (IfcPerson with doubled quotes, \S\, \X2\ and empty strings; IfcCartesianPoint and IfcCartesianPointList3D; IfcPolyline of 20 references; IfcTrimmedCurve with inline IfcParameterValue) plus TXG.

The columns: byte scan is the standalone scanner from #9473, for reference. The other three are the one spf_lexer::next<Policy>() of this branch under its three compile-time policies: full (full_tokens) decodes everything and is what the parser uses; index (index_tokens) ends a string without decoding it and returns numbers, enumerations and binaries as a literal with only a position, keeping keyword text, and is what the lazy index uses for the instance headers #name = KEYWORD(; attribute (attribute_tokens) is the same but does not copy keyword text either, and is what the lazy index uses inside each attribute list, so it is the policy under which most of a lazy index runs. Every policy sees the same tokens (the token count is the same for all three); they differ in what is decoded and copied. ns per token is wall time over token count under the attribute policy.

file tokens byte scan full tokenizer index policy attribute policy ns per token
strings 18.9 M 936 MB/s 166 MB/s 263 MB/s 309 MB/s 8.6
reals 10.5 M 1002 MB/s 195 MB/s 238 MB/s 291 MB/s 16.4
aggregates 13.2 M 1023 MB/s 202 MB/s 232 MB/s 272 MB/s 13.9
nested 12.8 M 1317 MB/s 221 MB/s 260 MB/s 301 MB/s 13.0
TXG (mixed) 13.3 M 990 MB/s 219 MB/s 273 MB/s 301 MB/s 14.5

Two things fall out. Strings are not the driver: the string-heavy file is the fastest per byte and the cheapest per token, because character_decoder::skip() runs the same state machine without collecting and a long token is a cheap token. And the cost is flat, 9–16 ns per token whatever the token is; reals look expensive per byte only because they are short tokens.

I then tried your last suggestion, passing over the non-identifier tokens inside next() and returning only ( ) , ;, names and strings. It drops the returned tokens by 13% on TXG and 34% on the reals file, and changes throughput by a few percent either way (TXG 301 → 290 MB/s, reals 291 → 284, nested 301 → 306), with lazy opens unchanged. The token counts explain it: on real files the structural tokens and the names are the majority, so most calls remain. Reverted, since it adds a loop for nothing measurable. next_n() or a page at a time would meet the same limit: the token still has to be produced.

So the honest reading is that the gap to the byte scanner is the per-token structure itself, not any token type, and closing it single-threaded would need the attribute structure (depth, commas) folded into the skip loop, which is the byte scanner in another place. I would rather not do that. What the branch does instead is chunk the index over threads (commit 13), which puts lazy under the default open on every model; the fourteen-model column is in the previous comment and the PR body.

For the record, the synthetic files also confirm the decoder cost you had in mind is real but small: with strings decoded (full tokenizer) the string file is the slowest at 166 MB/s; with them only ended it is the fastest.

@aothms

aothms commented Sep 14, 2026

Copy link
Copy Markdown
Member

You're right. Never realized that ifc models are so sparse on literal values. On Duplex_A I get to 12%.

Token category Count Structural count Ratio non-structural
References (#123) 80408 80408  
Comma 70149 70149  
Opening parenthesis ( 47977 47977  
Closing parenthesis ) 47977 47977  
Entity/type/keyword identifiers 28736 28736  
Semicolon ; 27538 27538  
Equals = 27529 27529  
Real numbers 19337    
$ null/unset 10366    
Strings 9071    
Enumerations (.FOO.) 5708    
Integers 68    
* omitted values 9    
  374873 330314 0.118864

But I think the fact that all tokens are more or less in the same order of magnitude tells us that the overhead is likely in the inability to optimise across the next() boundary or the allocation of the token object etc.

So let's go ahead with the inversion to a Consumer model.

template <typename Consumer> lexer::scan(Consumer& c) ...

Make the function body exactly that of lexer::next() but wrap it with a while loop and do not allocate Token objects but call the appropriate function - other than that exact equal. Then remove next().

struct lazy_index_consumer {
    static constexpr bool decode_strings = false;
    static constexpr bool decode_numbers = false;
    // ...

    uint32_t depth = 0;
    uint32_t attribute = 0;

    FORCEINLINE void operator_(size_t pos, char c) {
        switch (c) {
        case '(':
            ++depth;
            break;
        case ')':
            --depth;
            break;
        case ',':
            if (depth == 1)
                ++attribute;
            break;
        }
    }

    FORCEINLINE void identifier(size_t pos, uint32_t id) {
        // directly build inverse index
    }
};

struct full_parse_consumer {
    static constexpr bool decode_strings = true;
    static constexpr bool decode_numbers = true;

    IFC_FORCEINLINE void operator_(size_t, char);
    IFC_FORCEINLINE void identifier(size_t, uint32_t);
    IFC_FORCEINLINE void string(size_t, std::string_view);
    ...
};

Also a completely different subject, but since SPF structure is so regular and IFC entity types follow typical usage patterns we should really try and investigate profile guided optimizations. If a local test confirms this helps, please investigate a separate PR where we build this into the build_win / build_rocky / build_osx github action build pipelines.

…onsumer

The body of next() becomes spf_lexer::scan(Consumer&), the same code
wrapped in a loop that hands each token to the consumer's callbacks
(operator_, identifier, string, keyword, enumeration, binary, boolean,
integer, real, literal) instead of building a token object; each
callback returns whether to go on. The consumer's constexpr flags say
what is decoded: decode_strings, decode_values, keep_keywords. It lives
in spf_scan.h, with the SWAR helpers and number parsing it needs, so a
consumer inlines into the loop. next<Policy>() is kept as the consumer
that stops after one token: the attribute reader, header parser and
streamer pull tokens recursively and stay as they are.

The lazy index is now attribute_consumer: depth and attribute index from
the operators, every name straight into the inverse index, the bounds
of the first attribute if it is a string, done at the closing semicolon.
The attribute_tokens policy it replaces is gone.

Tokenizing 50 MB files with nothing decoded, in memory: index policy
through next() 214–247 MB/s, scan() with the inlined consumer 314–403
MB/s (TXG 247 → 345); through 64 KB pages 245–284 MB/s. Lazy open on one
thread TXG / 210_King / OKgate22 0.55 / 1.59 / 2.11 s → 0.52 / 1.57 /
2.05 s. The full tokenizer through the adapter is unchanged (TXG 202–210
MB/s against 195–219 before), as is the strict parse.

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
@Moult

Moult commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

(AI-written, not verified by Dion.)

The consumer model is in as commit 14. The body of next() is now spf_lexer::scan(Consumer&) in spf_scan.h: the same code wrapped in a loop, handing each token to the consumer's callbacks (operator_, identifier, string, keyword, enumeration, binary, boolean, integer, real, literal) instead of building a token; each callback returns whether to go on, and the consumer's constexpr flags say what is decoded (decode_strings, decode_values, keep_keywords). It lives in the header, with the SWAR helpers and number parsing it needs, so the consumer inlines into the loop. The lazy index is attribute_consumer: depth and attribute index from the operators, every name straight into the inverse index, the bounds of the first attribute if it is a string, done at the closing semicolon; the attribute_tokens policy is gone.

One deviation from what you wrote, and I'd rather state it than hide it: next() is not removed. It is kept as the consumer that stops after one token, six lines over scan(). The attribute reader, the header parser and the streamer's three-token lookahead pull tokens from inside recursive calls, 32 sites; inverting them into callbacks is a state-machine rewrite of the strict parser, which the profile never asked for (its time is in decoding and allocation). One tokenizer body, the index gets the inlined loop, the parser keeps pulling; the full tokenizer through the adapter measures the same as before (TXG 202–210 MB/s against 195–219). If you want the parser inverted too, that is its own PR.

Tokenizing 50 MB files with nothing decoded, in memory: the index policy through next() 214–247 MB/s, scan() with the inlined consumer 314–403 MB/s (TXG 247 → 345; the byte scanner it replaces 700–1300). On the fourteen models the index pass now runs at 266–343 MB/s in memory and 200–261 MB/s through 64 KB pages. It moves the lazy open only a little, because the index pass is no longer most of it: on one thread TXG / 210_King / OKgate22 0.55 / 1.59 / 2.11 → 0.54 / 1.57 / 2.07 s; on 12 threads 0.37 / 1.01 / 1.36 s against 0.44 / 1.19 / 1.94 s for the default open. Full tables, per-commit line counts and the profile section are in the PR body; 14 commits, net +2427 −415 lines (+1950 −403 outside tests). CI is green.

PGO, tested locally as you suggested: IfcParse and the schema plugins built with -fprofile-generate, trained by parsing TXG, 210_King and OKgate22 once each, rebuilt with -fprofile-use, against a plain build of the same tree. Median of three runs:

model plain, 1 thread PGO, 1 thread plain, 12 threads PGO, 12 threads
TXG 58 MB (in the training set) 1.03 s 0.96 s (−7%) 0.44 s 0.39 s (−11%)
210_King 147 MB (training) 2.70 s 2.46 s (−9%) 1.17 s 1.03 s (−11%)
OKgate22 231 MB (training) 5.03 s 4.71 s (−6%) 1.90 s 1.69 s (−11%)
107 MB private (not in training) 1.68 s 1.53 s (−9%) 0.72 s 0.62 s (−13%)
523 MB private, large instances (not in training) 6.80 s 6.55 s (−4%) 2.12 s 2.05 s (−4%)

So 6–13% on ordinary models, about 4% on one dominated by long real lists, and it holds out of sample. Two things to know before putting it in the pipelines: a first attempt showed no gain at all because the .gcda files are keyed by the object path and the profile-use build was in a different directory, so GCC silently used nothing (-Wno-missing-profile hid it; keep that warning on in CI). And the -fprofile-use compiles of the generated schema files took enough memory that my machine's watchdog killed the build twice at four and two jobs; on a 7 GB CI runner that needs -j1 for those units or a larger runner. If that is acceptable I'll open a separate PR for build_rocky, build_osx and build_win with a checked-in training model; the numbers above are the expected size of the win.

@Moult

Moult commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

(AI-written, not verified by Dion.)

A clarification on commit 14, so the numbers are not misread: it is an architecture change, not a performance one. Same fourteen models, the full matrix before the commit against the one after:

before → after
index pass in isolation (tokenizer with nothing decoded) 206–273 → 266–343 MB/s, +20–30% on every model
lazy open, 1 thread −6% to +2%, mean −1.2%
lazy open, 12 threads −1% to +11%, mean +1.8%
strict parse, 1 thread −1% to +7%, mean about +2%
memory, all modes identical

Run-to-run scatter on this machine is about ±3%, so the open times are flat and the strict parse is unchanged by construction (next() is the adapter). The isolated gain does not reach the open because the tokenizer pass is now a small share of it: on TXG 0.17 s of a 0.54 s single-threaded lazy open, the rest being the shells, the inverse sort and the GlobalId map, which the commit does not touch; at 12 threads that pass is divided by twelve and the serial merge dominates.

What the commit does deliver is the shape you asked for: one tokenizer body, the lazy index inlined into it as a consumer instead of a policy returning a token per call, and one policy fewer. The PR body's profile section says the same. The end-to-end before/after tables at the top of the PR body are unchanged by it.

Moult and others added 2 commits September 15, 2026 12:22
The inverse index is sorted once after a parse (and after a parallel
merge). std::sort on 1.7–5 million 12-byte records was the largest
serial phase left after the parse itself. sort_records() now does a
stable LSD radix sort on referenced_id, 11 bits per pass and as many
passes as the largest id needs, then applies record_less within each run
of equal ids, so the order is exactly what std::sort produced. Inputs
under 4096 records still use std::sort.

Experiment on top of the series; measured in isolation against the
previous commit, five models, best of three (strict parse 1 / 12 threads,
lazy open 1 / 12 threads): TXG 58 MB −7% / −16% / −10% / −19%,
210_King 147 MB −8% / −17% / −15% / −22%, OKgate22 231 MB −6% / −12% /
−11% / −17%, a 107 MB model −8% / −18% / −12% / −19%, a 523 MB model of
few large instances −3% / −4% / 0% / −5%.

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
After the parallel parse (and the parallel lazy index) the main thread
did, per instance, the by-type list push, the GlobalId extraction and
the name-table insert, then concatenated every worker's inverse records
and sorted the lot: on a 12-core box that serial merge was most of the
open. Now each worker sorts its own records, builds its own by-type
lists and reads its own GlobalIds; the main thread appends the by-type
lists, inserts names and GlobalIds, and merges the sorted runs pairwise
(inverse_index::merge_sorted, O(n log k)) instead of sorting again.
Instance order, GlobalId precedence and record order are unchanged.
The workers sort their runs in place (inverse_index::sort_in_place, no
radix buffer, no shrink): merge_sorted() copies the runs anyway, and a
buffer allocated on a worker's arena stays there after the open, which
showed as 3–10% more resident memory after a 12-thread open until the
runs sorted in place; peak memory at 12 threads is one transient copy
of the records higher (the merge), single-threaded it is unchanged.

Experiment on top of the series; measured in isolation against the
previous commit, five models, best of three: single-threaded within ±2%
(by construction); 12 threads, strict / lazy: TXG −16% / −25%,
210_King −23% / −26%, OKgate22 −25% / −20%, a 107 MB model −25% / −23%,
a 523 MB model of few large instances −9% / −7%. Together with the
radix sort: strict 12 threads −20% to −29%, lazy 12 threads −31% to
−35% on the ordinary models.

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
@Moult

Moult commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

(AI-written, not verified by Dion.)

Two more commits at the end of the branch, 15 and 16. They are experiments, kept separate so they are easy to drop: the series up to commit 14 is the one under review, and we assume 15 and 16 are discarded unless you want them. Nothing above them changed and the numbers in the PR body up to commit 14 are as they were; this comment's numbers are on top of those.

Why these two. With the tokenizer inlined, a lazy open of TXG on one thread (0.54 s) breaks down roughly as follows, from the callgrind profile and the isolated timings:

phase cost notes
tokenizer pass (scan) ~0.17 s parallelises
918k shell allocations ~0.06 s one malloc each
918k inserts into the name table ~0.07 s unordered_map
1.66 M inverse records: append and sort ~0.10 s introsort on 12-byte records
337k GlobalId inserts ~0.04 s hash of 22 bytes each
by-type lists, offsets ~0.03 s

Everything below the first row was serial even on 12 threads, which is why the lazy open only went from 0.54 s to 0.37 s on twelve cores, and the same serial merge is what held the strict parse at 2–3x on 12 cores: of its 0.44 s on TXG, roughly 0.3 s was the merge and the sort. So the two levers with contained changes were the sort and the merge.

Commit 15, radix sort of the inverse records. inverse_index::sort_records(): a stable LSD radix sort on referenced_id, 11 bits per pass and as many passes as the largest id needs, then record_less within each run of equal ids, so the order is exactly what std::sort gave; small inputs still use std::sort. 46 lines in one function. Measured in isolation against commit 14, five models, best of three:

model strict, 1 thread strict, 12 threads lazy, 1 thread lazy, 12 threads
TXG 58 MB 1.03 → 0.96 s (−7%) 0.43 → 0.36 s (−16%) 0.52 → 0.47 s (−10%) 0.36 → 0.29 s (−19%)
210_King 147 MB 2.68 → 2.46 s (−8%) 1.16 → 0.96 s (−17%) 1.57 → 1.34 s (−15%) 1.00 → 0.78 s (−22%)
OKgate22 231 MB 5.00 → 4.70 s (−6%) 1.89 → 1.67 s (−12%) 2.05 → 1.83 s (−11%) 1.33 → 1.11 s (−17%)
107 MB (private) 1.68 → 1.55 s (−8%) 0.73 → 0.60 s (−18%) 1.02 → 0.90 s (−12%) 0.62 → 0.50 s (−19%)
523 MB (private, few large instances) 6.91 → 6.71 s (−3%) 2.10 → 2.01 s (−4%) 2.97 → 2.96 s (0%) 1.49 → 1.42 s (−5%)

Commit 16, sort and index per worker, merge sorted runs. Each parser (and index) worker sorts its own records, builds its own by-type lists and reads its own GlobalIds; the main thread appends the lists, inserts names and GlobalIds, and merges the sorted runs pairwise (inverse_index::merge_sorted, O(n log k)) instead of concatenating and sorting. Instance order, GlobalId precedence and record order are unchanged; the equality tests cover it. The workers sort their runs in place (no radix buffer on the worker's arena, no shrink: the merge copies them anyway). About 140 lines, mostly moved. In isolation against commit 14:

model strict, 1 thread strict, 12 threads lazy, 1 thread lazy, 12 threads
TXG 58 MB +1% 0.43 → 0.36 s (−16%) +2% 0.36 → 0.27 s (−25%)
210_King 147 MB +1% 1.16 → 0.89 s (−23%) −1% 1.00 → 0.74 s (−26%)
OKgate22 231 MB 0% 1.89 → 1.41 s (−25%) +2% 1.33 → 1.06 s (−20%)
107 MB (private) +1% 0.73 → 0.55 s (−25%) +1% 0.62 → 0.48 s (−23%)
523 MB (private) −1% 2.10 → 1.92 s (−9%) −2% 1.49 → 1.38 s (−7%)

Single-threaded it is neutral by construction. A third experiment, deferring the lazy GlobalId map to first use, gained 0–8% on the 12-thread lazy open only and is not included.

Both together, against commit 14, all fourteen models:

model strict, 1 thread strict, 12 threads lazy, 1 thread lazy, 12 threads peak memory, strict 12 threads Python default open Python lazy open
TXG 58 MB 1.08 → 0.99 s (-8%) 0.44 → 0.37 s (-16%) 0.54 → 0.47 s (-13%) 0.37 → 0.28 s (-24%) 381 → 392 MB 0.44 → 0.37 s (-16%) 0.37 → 0.28 s (-24%)
210_King 147 MB 2.73 → 2.50 s (-8%) 1.19 → 0.90 s (-24%) 1.57 → 1.35 s (-14%) 1.01 → 0.75 s (-26%) 903 → 933 MB 1.19 → 0.90 s (-24%) 1.01 → 0.76 s (-25%)
OKgate22 231 MB 5.06 → 4.78 s (-6%) 1.94 → 1.40 s (-28%) 2.07 → 1.91 s (-8%) 1.36 → 1.09 s (-20%) 1380 → 1434 MB 1.93 → 1.42 s (-26%) 1.36 → 1.11 s (-18%)
10 MB (private) 0.18 → 0.15 s (-17%) 0.08 → 0.06 s (-25%) 0.10 → 0.09 s (-10%) 0.07 → 0.06 s (-14%) 81 → 78 MB 0.09 → 0.07 s (-22%) 0.08 → 0.06 s (-25%)
74 MB (private) 1.26 → 1.13 s (-10%) 0.53 → 0.41 s (-23%) 0.60 → 0.56 s (-7%) 0.42 → 0.32 s (-24%) 485 → 479 MB 0.50 → 0.43 s (-14%) 0.40 → 0.33 s (-18%)
81 MB (private) 1.30 → 1.22 s (-6%) 0.52 → 0.42 s (-19%) 0.69 → 0.63 s (-9%) 0.45 → 0.37 s (-18%) 478 → 470 MB 0.55 → 0.44 s (-20%) 0.45 → 0.37 s (-18%)
107 MB (private) 1.74 → 1.60 s (-8%) 0.75 → 0.57 s (-24%) 1.03 → 0.91 s (-12%) 0.64 → 0.49 s (-23%) 608 → 622 MB 0.74 → 0.57 s (-23%) 0.63 → 0.50 s (-21%)
249 MB (private) 4.26 → 3.86 s (-9%) 1.92 → 1.42 s (-26%) 2.32 → 2.01 s (-13%) 1.61 → 1.15 s (-29%) 1438 → 1478 MB 1.91 → 1.45 s (-24%) 1.60 → 1.16 s (-28%)
279 MB (private) 3.77 → 3.71 s (-2%) 1.13 → 1.04 s (-8%) 1.61 → 1.57 s (-2%) 0.83 → 0.77 s (-7%) 1005 → 1015 MB 1.13 → 1.06 s (-6%) 0.83 → 0.79 s (-5%)
315 MB (private) 5.59 → 5.12 s (-8%) 2.24 → 1.70 s (-24%) 2.88 → 2.47 s (-14%) 1.86 → 1.37 s (-26%) 1852 → 1915 MB 2.24 → 1.74 s (-22%) 1.82 → 1.40 s (-23%)
349 MB (private) 5.69 → 5.30 s (-7%) 2.41 → 1.82 s (-24%) 3.18 → 2.81 s (-12%) 2.02 → 1.52 s (-25%) 1967 → 1979 MB 2.44 → 1.81 s (-26%) 2.01 → 1.54 s (-23%)
523 MB (private) 6.94 → 6.91 s (-0%) 2.11 → 2.04 s (-3%) 2.99 → 2.87 s (-4%) 1.51 → 1.40 s (-7%) 1825 → 1851 MB 2.14 → 1.99 s (-7%) 1.53 → 1.43 s (-7%)
603 MB (private) 8.55 → 7.89 s (-8%) 3.23 → 2.67 s (-17%) 4.38 → 3.99 s (-9%) 2.61 → 2.12 s (-19%) 2649 → 2710 MB 3.29 → 2.68 s (-19%) 2.64 → 2.14 s (-19%)
787 MB (private) 12.48 → 11.27 s (-10%) 5.62 → 4.14 s (-26%) 7.28 → 6.18 s (-15%) 4.85 → 3.55 s (-27%) 4384 → 4454 MB 5.69 → 4.18 s (-27%) 4.88 → 3.55 s (-27%)

Memory. Single-threaded nothing changes. At 12 threads the peak is one transient copy of the records higher, for the merge: +1–4% strict, +0–10% lazy (the 787 MB model: 2684 → 2863 MB). Resident memory after the open is within ±2% once the allocator's free pages are trimmed, and 0–7% higher before trimming, which is glibc keeping the freed per-worker runs on the worker arenas (MALLOC_ARENA_MAX=1 removes the difference). A first form of commit 16 ran the radix sort inside the workers; its buffers stayed on those arenas and resident memory after a 12-thread open was 3–10% higher, so the workers now sort their runs in place with std::sort, which costs nothing at 12 threads (the runs are a twelfth of the records each) and keeps the radix sort for the single-threaded paths.

If you would rather keep the PR at commit 14, say so and I will drop these two; they stand on their own and can come back later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants