Skip to content

[AI-generated, unverified] ifcparse: faster, leaner open(): reader inlining, dense tables, in-place references, fewer allocations, lazy loading, parallel parsing - #9473

Closed
Moult wants to merge 12 commits into
v0.9.0from
open-perf
Closed

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

Conversation

@Moult

@Moult Moult commented Sep 11, 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. It is opened as one PR to start the discussion; every commit is self-contained with its own tests and numbers, so it can be split into stacked PRs in commit order, or grouped, whichever you prefer.

Before and after

C++ file constructor 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.

model v0.9.0 head, 1 thread this branch, 1 thread this branch, 12 threads this branch, paged, 12 threads this branch, lazy
TXG 58 MB 1.39 s, 357 MB (peak 463) 1.06 s (-24%), 283 MB (-21%) 0.46 s (-67%), peak 389 MB 0.46 s, peak 383 MB (-17%) 0.49 s, 141 MB (-61%)
210_King 147 MB 3.77 s, 877 MB (peak 1138) 2.77 s (-27%), 669 MB (-24%) 1.24 s (-67%), peak 915 MB 1.25 s, peak 819 MB (-28%) 1.42 s, 370 MB (-58%)
OKgate22 231 MB 6.21 s, 1364 MB (peak 1743) 5.27 s (-15%), 1079 MB (-21%) 2.05 s (-67%), peak 1422 MB 2.03 s, peak 1238 MB (-29%) 2.58 s, 533 MB (-61%)
10 MB (private) 0.23 s, 61 MB (peak 90) 0.19 s (-17%), 50 MB (-18%) 0.09 s (-61%), peak 83 MB 0.09 s, peak 88 MB (-2%) 0.09 s, 33 MB (-46%)
74 MB (private) 1.62 s, 444 MB (peak 573) 1.23 s (-24%), 357 MB (-20%) 0.51 s (-69%), peak 497 MB 0.50 s, peak 474 MB (-17%) 0.53 s, 158 MB (-64%)
81 MB (private) 1.77 s, 443 MB (peak 584) 1.35 s (-24%), 348 MB (-21%) 0.53 s (-70%), peak 496 MB 0.52 s, peak 466 MB (-20%) 0.61 s, 204 MB (-54%)
107 MB (private) 2.42 s, 581 MB (peak 792) 1.79 s (-26%), 458 MB (-21%) 0.77 s (-68%), peak 632 MB 0.74 s, peak 577 MB (-27%) 0.88 s, 279 MB (-52%)
249 MB (private) 5.62 s, 1335 MB (peak 1752) 4.38 s (-22%), 1024 MB (-23%) 1.98 s (-65%), peak 1428 MB 1.90 s, peak 1231 MB (-30%) 2.05 s, 551 MB (-59%)
279 MB (private) 4.81 s, 720 MB (peak 1044) 3.90 s (-19%), 680 MB (-6%) 1.22 s (-75%), peak 1016 MB 1.15 s, peak 789 MB (-24%) 0.96 s, 96 MB (-87%)
315 MB (private) 7.55 s, 1791 MB (peak 2333) 5.82 s (-23%), 1400 MB (-22%) 2.38 s (-68%), peak 1895 MB 2.29 s, peak 1629 MB (-30%) 2.51 s, 721 MB (-60%)
349 MB (private) 7.79 s, 1913 MB (peak 2551) 5.98 s (-23%), 1523 MB (-20%) 2.52 s (-68%), peak 2042 MB 2.49 s, peak 1744 MB (-32%) 2.80 s, 851 MB (-56%)
523 MB (private) 8.91 s, 1320 MB (peak 1913) 7.21 s (-19%), 1251 MB (-5%) 2.24 s (-75%), peak 1844 MB 2.13 s, peak 1373 MB (-28%) 1.81 s, 161 MB (-88%)
603 MB (private) 10.97 s, 2289 MB (peak 3081) 8.50 s (-23%), 1921 MB (-16%) 3.35 s (-69%), peak 2656 MB 3.25 s, peak 2105 MB (-32%) 3.55 s, 872 MB (-62%)
787 MB (private) 17.20 s, 4304 MB (peak 5811) 13.06 s (-24%), 3351 MB (-22%) 5.83 s (-66%), peak 4554 MB 5.64 s, peak 3819 MB (-34%) 6.61 s, 2083 MB (-52%)

Python ifcopenshell.open on 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.

model v0.8.5 v0.9.0 head this branch, default this branch, lazy
TXG 58 MB 2.81 s, 10.6x file size 1.45 s, 6.2x file size 0.47 s, 5.1x file size (-68%) 0.48 s, 2.5x file size (-67%)
210_King 147 MB 7.14 s, 11.1x file size 3.87 s, 6.0x file size 1.23 s, 4.6x file size (-68%) 1.40 s, 2.5x file size (-64%)
OKgate22 231 MB 11.44 s, 10.4x file size 6.46 s, 5.9x file size 2.00 s, 4.7x file size (-69%) 2.57 s, 2.3x file size (-60%)
10 MB (private) 0.50 s, 12.6x file size 0.25 s, 6.5x file size 0.09 s, 5.7x file size (-64%) 0.10 s, 3.6x file size (-60%)
74 MB (private) 3.56 s, 10.6x file size 1.68 s, 6.1x file size 0.51 s, 5.2x file size (-70%) 0.54 s, 2.2x file size (-68%)
81 MB (private) 3.57 s, 10.4x file size 1.82 s, 5.5x file size 0.52 s, 4.6x file size (-71%) 0.60 s, 2.5x file size (-67%)
107 MB (private) 5.16 s, 11.4x file size 2.49 s, 5.5x file size 0.76 s, 4.3x file size (-69%) 0.89 s, 2.6x file size (-64%)
249 MB (private) 11.93 s, 9.8x file size 5.78 s, 5.4x file size 1.97 s, 4.3x file size (-66%) 2.04 s, 2.2x file size (-65%)
279 MB (private) 9.65 s, 3.2x file size 5.13 s, 2.6x file size 1.20 s, 2.5x file size (-77%) 0.96 s, 0.4x file size (-81%)
315 MB (private) 14.74 s, 10.8x file size 7.82 s, 5.8x file size 2.30 s, 4.6x file size (-71%) 2.49 s, 2.3x file size (-68%)
349 MB (private) 16.52 s, 11.1x file size 8.37 s, 5.5x file size 2.43 s, 4.4x file size (-71%) 2.76 s, 2.4x file size (-67%)
523 MB (private) 18.01 s, 3.1x file size 9.18 s, 2.5x file size 2.23 s, 2.4x file size (-76%) 1.78 s, 0.3x file size (-81%)
603 MB (private) 22.52 s, 6.5x file size 11.37 s, 3.9x file size 3.37 s, 3.2x file size (-70%) 3.51 s, 1.4x file size (-69%)
787 MB (private) 36.95 s, 11.5x file size 18.12 s, 5.5x file size 5.71 s, 4.3x file size (-68%) 6.54 s, 2.7x file size (-64%)

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:

  • 82% of open time is per-instance attribute parsing (the tokenizer alone 50%, malloc ~20%, a string-keyed type lookup per instance 5.8%, three out-of-line buffer accessors 5.6%).
  • At peak, 450 MB of heap: the whole-file buffer (58 MB), a transient reference side table (64 MB), 99 MB of malloc bookkeeping from 10.4 million allocations (7 live per instance), a 30 MB tree map for GlobalIds, a 32 MB hash map for instance names.
  • Common semantic workloads touch under 1.5% of a model's instances (walls with their property sets: 0.4–1.2%; the spatial tree: 0.5–0.8%), yet every instance is parsed up front.

What

Twelve commits, each independently testable. The first is #9474 on its own. Withdrawn after review: SWIG fast-proxy (breaks validate_stub) and dense_id_map (a vector-indexed instance-name table; its cost is stated below so it can be reconsidered with numbers).

# commit what it does
1 013ebd2 inline the file reader accessors, pre-size the inverse vector (#9474)
2 bb224914f guid_map: unordered_map keyed by std::array<char, 22>; only a 22-character GlobalId can be stored or found
3 d21e96847 resolve references in the attribute slots instead of a side table
4 f53b70db0 two heap allocations per instance instead of four (no cached slot pointer)
5 1bc1c33ad lazy loading, opt in (file::lazy_loading(true), open(path, lazy=True))
6 9e81b0e31 parse instances in parallel (default one thread per core, capped at 16; IFCOPENSHELL_PARSE_THREADS)
7 3317e4090 keep the file offset in errors about missing names in lists (what ifcopenshell.validate keys on)
8 991e7a574 file_reader::for_each_span / reopen; paged lazy source with a 4 MB cache; span-based parallel chunking with a reader per worker
9 27a15415e cache the current page in the paged reader
10 1003b5703 opt in to running the full strict parse through the paged reader (file::paged_reading(true)), serial or parallel; the whole file is never read into memory
11 b10b1abe1 build the lazy index through the shared tokenizer: spf_lexer::scan_attributes(sink), file_reader::span(), one instance-header loop for the parallel chunker and the lazy index; the byte scanner is deleted
12 db71cb3 inline the tokenizer's hot helpers (SWAR, paged size(), 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 #reference with the index of the attribute it sits in, so get_inverse works), 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 same load_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++ file constructor, 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).

model instances v0.9.0 head now, 1 thread now, 12 threads now, paged, 1 thread now, paged, 12 threads lazy (paged)
TXG 58 MB 917,995 1.39 s, 357 / 463 MB 1.06 s, 283 / 373 MB 0.46 s, 294 / 389 MB 1.08 s, 287 / 318 MB 0.46 s, 343 / 383 MB 0.49 s, 141 / 175 MB
210_King 147 MB 2,731,958 3.77 s, 877 / 1138 MB 2.77 s, 669 / 883 MB 1.24 s, 677 / 915 MB 2.91 s, 673 / 739 MB 1.25 s, 720 / 819 MB 1.42 s, 370 / 440 MB
OKgate22 231 MB 3,825,882 6.21 s, 1364 / 1743 MB 5.27 s, 1079 / 1385 MB 2.05 s, 1092 / 1422 MB 5.42 s, 1083 / 1157 MB 2.03 s, 1130 / 1238 MB 2.58 s, 533 / 612 MB
10 MB (private) 209,548 0.23 s, 61 / 90 MB 0.19 s, 50 / 78 MB 0.09 s, 55 / 83 MB 0.19 s, 54 / 72 MB 0.09 s, 70 / 88 MB 0.09 s, 33 / 52 MB
74 MB (private) 1,162,030 1.62 s, 444 / 573 MB 1.23 s, 357 / 463 MB 0.51 s, 381 / 497 MB 1.27 s, 361 / 392 MB 0.50 s, 431 / 474 MB 0.53 s, 158 / 192 MB
81 MB (private) 1,527,802 1.77 s, 443 / 584 MB 1.35 s, 348 / 465 MB 0.53 s, 372 / 496 MB 1.39 s, 353 / 388 MB 0.52 s, 419 / 466 MB 0.61 s, 204 / 241 MB
107 MB (private) 2,152,533 2.42 s, 581 / 792 MB 1.79 s, 458 / 615 MB 0.77 s, 462 / 632 MB 1.88 s, 462 / 511 MB 0.74 s, 511 / 577 MB 0.88 s, 279 / 329 MB
249 MB (private) 3,884,378 5.62 s, 1335 / 1752 MB 4.38 s, 1024 / 1368 MB 1.98 s, 1057 / 1428 MB 4.53 s, 1028 / 1122 MB 1.90 s, 1095 / 1231 MB 2.05 s, 551 / 658 MB
279 MB (private) 625,577 4.81 s, 720 / 1044 MB 3.90 s, 680 / 984 MB 1.22 s, 705 / 1016 MB 4.12 s, 684 / 709 MB 1.15 s, 757 / 789 MB 0.96 s, 96 / 122 MB
315 MB (private) 5,618,442 7.55 s, 1791 / 2333 MB 5.82 s, 1400 / 1820 MB 2.38 s, 1440 / 1895 MB 5.96 s, 1404 / 1509 MB 2.29 s, 1470 / 1629 MB 2.51 s, 721 / 839 MB
349 MB (private) 6,430,306 7.79 s, 1913 / 2551 MB 5.98 s, 1523 / 1988 MB 2.52 s, 1534 / 2042 MB 6.26 s, 1527 / 1642 MB 2.49 s, 1575 / 1744 MB 2.80 s, 851 / 974 MB
523 MB (private) 1,056,071 8.91 s, 1320 / 1913 MB 7.21 s, 1251 / 1807 MB 2.24 s, 1267 / 1844 MB 7.64 s, 1255 / 1288 MB 2.13 s, 1318 / 1373 MB 1.81 s, 161 / 195 MB
603 MB (private) 6,579,063 10.97 s, 2289 / 3081 MB 8.50 s, 1921 / 2605 MB 3.35 s, 1934 / 2656 MB 8.90 s, 1925 / 2006 MB 3.25 s, 1952 / 2105 MB 3.55 s, 872 / 978 MB
787 MB (private) 16,232,582 17.20 s, 4304 / 5811 MB 13.06 s, 3351 / 4424 MB 5.83 s, 3380 / 4554 MB 13.55 s, 3355 / 3641 MB 5.64 s, 3416 / 3819 MB 6.61 s, 2083 / 2376 MB

Reading across the columns:

  • Strict, single thread: 15–27% faster than v0.9.0 head; steady memory −16% to −24% on twelve models and −5%/−6% on the 279 MB and 523 MB ones (few, very large instances, so allocation work is a small share of their parse); peak −13% to −24% and −6%.
  • 12 threads: a further 2.1–3.2x over one thread; steady memory +1–10%, peak +2–12% (the merge copies).
  • Paged strict parse (commit 10) against the in-memory strict parse: +0–6% on one thread, −6% to +1% on 12 threads; peak RSS drops by the size of the file (−8% to −29%). Before commit 12 the same comparison was +16–24% and +3–17%. At 12 threads the steady figure is 15–55 MB higher: each worker's page cache is 4 MB and glibc keeps 64 KB blocks; retention, not a leak, though not verified.
  • Lazy open: 128–157 bytes per instance (2.1–3.3x file size on eleven models, 0.3x on the 279 MB and 523 MB ones, 1.5x on the 603 MB one). Against the 12-thread strict parse it is 21% faster to 26% slower; its case is memory, and time only where instances are large.

Throughput of the stages, single thread, whole file, and the cost of reading one attribute of every instance right after a lazy open:

model byte scanner (previous) index pass, shared tokenizer, in memory same, paged tokenizer, in memory tokenizer, paged full parse, 1 thread lazy open + one attribute of every instance
TXG 58 MB 1060 MB/s 434 MB/s 286 MB/s 200 MB/s 191 MB/s 55 MB/s 0.49 + 0.58 s
210_King 147 MB 715 MB/s 354 MB/s 248 MB/s 183 MB/s 166 MB/s 53 MB/s 1.42 + 1.45 s
OKgate22 231 MB 1002 MB/s 380 MB/s 265 MB/s 193 MB/s 183 MB/s 44 MB/s 2.58 + 4.18 s
10 MB (private) 796 MB/s 363 MB/s 259 MB/s 202 MB/s 181 MB/s 53 MB/s 0.09 + 0.09 s
74 MB (private) 1142 MB/s 428 MB/s 296 MB/s 207 MB/s 198 MB/s 60 MB/s 0.53 + 0.73 s
81 MB (private) 837 MB/s 397 MB/s 284 MB/s 194 MB/s 181 MB/s 60 MB/s 0.61 + 0.67 s
107 MB (private) 838 MB/s 351 MB/s 259 MB/s 186 MB/s 169 MB/s 60 MB/s 0.88 + 0.93 s
249 MB (private) 935 MB/s 424 MB/s 305 MB/s 198 MB/s 184 MB/s 57 MB/s 2.05 + 2.28 s
279 MB (private) 783 MB/s 527 MB/s 456 MB/s 147 MB/s 147 MB/s 72 MB/s 0.96 + 4.39 s
315 MB (private) 882 MB/s 393 MB/s 280 MB/s 196 MB/s 180 MB/s 54 MB/s 2.51 + 3.24 s
349 MB (private) 836 MB/s 367 MB/s 265 MB/s 188 MB/s 172 MB/s 58 MB/s 2.80 + 3.09 s
523 MB (private) 784 MB/s 513 MB/s 476 MB/s 148 MB/s 150 MB/s 73 MB/s 1.81 + 8.16 s
603 MB (private) 894 MB/s 447 MB/s 341 MB/s 181 MB/s 175 MB/s 71 MB/s 3.55 + 5.44 s
787 MB (private) 853 MB/s 367 MB/s 272 MB/s 206 MB/s 190 MB/s 60 MB/s 6.61 + 6.49 s

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.

model v0.8.5 this branch, default this branch, lazy
TXG 58 MB 2.81 s, 10.6x (11.6x) 0.47 s, 5.1x (6.5x) 0.48 s, 2.5x (2.8x)
210_King 147 MB 7.14 s, 11.1x (12.1x) 1.23 s, 4.6x (6.1x) 1.40 s, 2.5x (2.9x)
OKgate22 231 MB 11.44 s, 10.4x (11.4x) 2.00 s, 4.7x (6.1x) 2.57 s, 2.3x (2.6x)
10 MB (private) 0.50 s, 12.6x (13.6x) 0.09 s, 5.7x (7.1x) 0.10 s, 3.6x (3.9x)
74 MB (private) 3.56 s, 10.6x (11.6x) 0.51 s, 5.2x (6.5x) 0.54 s, 2.2x (2.4x)
81 MB (private) 3.57 s, 10.4x (11.4x) 0.52 s, 4.6x (6.0x) 0.60 s, 2.5x (2.8x)
107 MB (private) 5.16 s, 11.4x (12.4x) 0.76 s, 4.3x (5.8x) 0.89 s, 2.6x (3.0x)
249 MB (private) 11.93 s, 9.8x (10.8x) 1.97 s, 4.3x (5.7x) 2.04 s, 2.2x (2.6x)
279 MB (private) 9.65 s, 3.2x (4.2x) 1.20 s, 2.5x (3.6x) 0.96 s, 0.4x (0.4x)
315 MB (private) 14.74 s, 10.8x (11.8x) 2.30 s, 4.6x (6.0x) 2.49 s, 2.3x (2.6x)
349 MB (private) 16.52 s, 11.1x (12.1x) 2.43 s, 4.4x (5.8x) 2.76 s, 2.4x (2.8x)
523 MB (private) 18.01 s, 3.1x (4.1x) 2.23 s, 2.4x (3.5x) 1.78 s, 0.3x (0.3x)
603 MB (private) 22.52 s, 6.5x (7.5x) 3.37 s, 3.2x (4.4x) 3.51 s, 1.4x (1.6x)
787 MB (private) 36.95 s, 11.5x (12.5x) 5.71 s, 4.3x (5.8x) 6.54 s, 2.7x (3.0x)

The cost of the withdrawn dense_id_map

Same code with and without the vector-indexed name table (branch open-perf-dense-ids), so the difference is the container alone:

model mode with without (this PR) cost of leaving it out
TXG 58 MB strict, 1 thread 0.99 s, 260 MB 1.04 s, 298 MB +5%, +38 MB
TXG lazy 0.45 s 0.49 s +9%
210_King 147 MB strict, 1 thread 2.70 s, 630 MB 2.80 s, 684 MB +4%, +54 MB
210_King strict, 12 threads 1.15 s 1.28 s +11%
OKgate22 231 MB strict, 1 thread 3.59 s, 988 MB 5.18 s, 1094 MB +44%, +106 MB
OKgate22 strict, 12 threads 1.61 s 2.06 s +28%
OKgate22 lazy 1.80 s 2.65 s +47%

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

  • 16 Catch2 parse cases (BUILD_IFCOPENSHELL_PARSE_TESTS=ON), including: serial vs 5-thread equality, paged serial and paged parallel equality, on a 12 MB replicated fixture; per-instance to_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.
  • Python: file, inverse, entity, gc, parse, open, stream, guid, global-id, validate, stub validator, the whole test/api tree, plus test/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 tested: RocksDB backend (code paths untouched but not exercised), Windows/macOS, Bonsai. Commit 1 removes exported symbols, so the Python wrapper must be rebuilt against the library. Materialising lazy instances from several threads at once is not safe; neither was any other mutation.

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

…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
@Moult Moult changed the title [AI-generated, unverified] ifcparse: faster, leaner open(): reader inlining, dense tables, in-place references, fewer allocations, lazy loading [AI-generated, unverified] ifcparse: faster, leaner open(): reader inlining, dense tables, in-place references, fewer allocations, lazy loading, parallel parsing Sep 11, 2026
@aothms

aothms commented Sep 11, 2026

Copy link
Copy Markdown
Member

Thanks.

013ebd2
ifcparse: inline the file reader accessors and pre-size the inverse index

This makes sense, no impact on complexity

10d7c4a
ifcwrap: build the Python wrapper with SWIG -fastproxy -fastdispatch

I didn't know this existed and happy to merge it in

597ba12
ifcparse: index instances by name in a dense vector instead of a hash map

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
ifcparse: store the GlobalId index in a hash map with inline keys

I don't know if I like this. Did you consider a normal map with:

  • std::array<char, 22> (probably your preference because of lazyness)
  • std::array<std::uint8_t, 16> (requires the decoding but is even more compact)

bf91629
ifcparse: resolve entity references in the attribute slots instead of a side table

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
ifcparse: two heap allocations per instance instead of four

I don't understand why you retain storage_ as a member. That adds 8 bytes with little utility.

storage_type* storage() noexcept {
    return reinterpret_cast<storage_type*>(
        size_and_indices_ + slots_offset_(size())
    );
}

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
ifcparse: lazy loading, opt in with file::lazy_loading() / open(lazy=True)

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
(origin/open-perf) ifcparse: parse instances in parallel

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.

@Moult

Moult commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

(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: validate_stub (a CI test) compares each proxy method's def signature against the .pyi, and fast-proxy replaces those defs with assignments. This PR is rebased on the same first commit and its CI failures (formatting of the new test file, a missing "at offset" in one error message, .pyi entries for the new methods) are fixed. The PR body now has results on fourteen models, 10 MB to 787 MB: the three public ones and eleven that Dion supplied privately for benchmarking, identified by size only.

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:

file map build random lookup memory
TXG (918k names, dense) std::unordered_map 22 ms 8.5 ns 35 MB
dense_id_map 6 ms 1.2 ns 8 MB
judy-template 21 ms 27 ns 7 MB
210_King (2.7M names, start at #3123171, 46% occupancy) std::unordered_map 70 ms 14 ns 105 MB
dense_id_map 54 ms 3.8 ns 58 MB
judy-template 67 ms 51 ns 22 MB
OKgate22 (3.8M names, 55% occupancy) std::unordered_map 97 ms 21 ns 147 MB
dense_id_map 51 ms 7.1 ns 64 MB
judy-template 109 ms 101 ns 57 MB

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 std::unordered_map<std::array<char, 22>, ...> as you suggested; keys of any other length (only invalid files) go to a std::map<std::string, ...>. The 16-byte decoded form would save 6 bytes per rooted instance and cost a decode per lookup; there is no C++ decoder in the tree today, happy to switch if you prefer it.

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
model byte scan tokenizer, in memory tokenizer, paged full parse, 1 thread lazy open then one attribute of every instance
TXG 58 MB 1060 MB/s 195 MB/s 136 MB/s 61 MB/s 0.41 + 0.64 s
210_King 147 MB 715 MB/s 181 MB/s 119 MB/s 54 MB/s 1.09 + 1.56 s
OKgate22 231 MB 1002 MB/s 194 MB/s 129 MB/s 62 MB/s 1.52 + 2.43 s
10 MB (private) 796 MB/s 201 MB/s 135 MB/s 62 MB/s 0.07 + 0.10 s
74 MB (private) 1142 MB/s 207 MB/s 140 MB/s 67 MB/s 0.43 + 0.80 s
81 MB (private) 837 MB/s 196 MB/s 135 MB/s 70 MB/s 0.46 + 0.75 s
107 MB (private) 838 MB/s 183 MB/s 126 MB/s 66 MB/s 0.66 + 1.03 s
249 MB (private) 935 MB/s 200 MB/s 137 MB/s 65 MB/s 1.68 + 2.53 s
279 MB (private) 783 MB/s 148 MB/s 113 MB/s 73 MB/s 1.09 + 4.96 s
315 MB (private) 882 MB/s 197 MB/s 134 MB/s 65 MB/s 2.01 + 3.13 s
349 MB (private) 836 MB/s 189 MB/s 128 MB/s 67 MB/s 2.09 + 3.44 s
523 MB (private) 784 MB/s 150 MB/s 116 MB/s 74 MB/s 2.04 + 9.47 s
603 MB (private) 894 MB/s 183 MB/s 133 MB/s 78 MB/s 2.90 + 6.19 s
787 MB (private) 853 MB/s 206 MB/s 142 MB/s 69 MB/s 5.10 + 6.94 s

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 spf_lexer primitive that skips one instance while reporting its references and their attribute index (reusing the lexer's own string, comment and whitespace handling), and a compile-time mode on the streamer that discards the storage path. One owner for the syntax, most of the speed kept.

Streaming. This was the main design objection, so here is what changed and what it measures. Four steps, in commits 9–11:

  1. file_reader::for_each_span(begin, end, fn) 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 span by span, so a page boundary can fall anywhere; the parallel chunker finds DATA;, the comment check and the line-start # boundaries in one streaming pass. Nothing left in the parse path needs a pointer to the whole file.
  2. The lazy source is a paged reader with a 4 MB cache instead of a heap copy of the file. file_reader::reopen() gives each parallel worker its own page cache.
  3. The paged reader caches the page its cursor is on, revalidated by an eviction counter. Tokenizing through pages went from 19–21% of the in-memory speed to 66–77% of it (113–142 MB/s against 148–207 MB/s).
  4. file::paged_reading(true) runs the full strict parse, serial or parallel, through the paged reader. The whole file is never read into memory.

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
model instances v0.9.0 head now, 1 thread now, 12 threads now, paged, 1 thread now, paged, 12 threads lazy (paged)
TXG 58 MB 917,995 1.39 s, 357 / 463 MB 0.95 s, 252 / 341 MB 0.42 s, 269 / 365 MB 1.15 s, 256 / 287 MB 0.46 s, 318 / 358 MB 0.41 s, 110 / 143 MB
210_King 147 MB 2,731,958 3.77 s, 877 / 1138 MB 2.74 s, 622 / 836 MB 1.20 s, 662 / 921 MB 3.18 s, 626 / 692 MB 1.26 s, 706 / 825 MB 1.09 s, 339 / 408 MB
OKgate22 231 MB 3,825,882 6.21 s, 1364 / 1743 MB 3.70 s, 980 / 1287 MB 1.64 s, 995 / 1331 MB 4.40 s, 984 / 1059 MB 1.75 s, 1038 / 1154 MB 1.52 s, 435 / 515 MB
10 MB (private) 209,548 0.23 s, 61 / 90 MB 0.16 s, 42 / 71 MB 0.08 s, 46 / 77 MB 0.19 s, 46 / 64 MB 0.09 s, 61 / 82 MB 0.07 s, 26 / 44 MB
74 MB (private) 1,162,030 1.62 s, 444 / 573 MB 1.10 s, 326 / 432 MB 0.48 s, 348 / 481 MB 1.32 s, 330 / 361 MB 0.51 s, 397 / 458 MB 0.43 s, 127 / 162 MB
81 MB (private) 1,527,802 1.77 s, 443 / 584 MB 1.16 s, 295 / 412 MB 0.49 s, 318 / 462 MB 1.35 s, 299 / 335 MB 0.53 s, 366 / 432 MB 0.46 s, 151 / 188 MB
107 MB (private) 2,152,533 2.42 s, 581 / 792 MB 1.63 s, 403 / 559 MB 0.71 s, 411 / 590 MB 1.94 s, 407 / 455 MB 0.75 s, 462 / 536 MB 0.66 s, 223 / 272 MB
249 MB (private) 3,884,378 5.62 s, 1335 / 1752 MB 3.81 s, 892 / 1236 MB 1.81 s, 931 / 1303 MB 4.48 s, 896 / 990 MB 1.88 s, 969 / 1106 MB 1.68 s, 419 / 526 MB
279 MB (private) 625,577 4.81 s, 720 / 1044 MB 3.80 s, 680 / 984 MB 1.24 s, 705 / 1016 MB 4.73 s, 684 / 709 MB 1.33 s, 758 / 790 MB 1.09 s, 96 / 122 MB
315 MB (private) 5,618,442 7.55 s, 1791 / 2333 MB 4.86 s, 1347 / 1763 MB 2.18 s, 1388 / 1904 MB 5.79 s, 1351 / 1452 MB 2.34 s, 1422 / 1644 MB 2.01 s, 664 / 782 MB
349 MB (private) 6,430,306 7.79 s, 1913 / 2551 MB 5.22 s, 1299 / 1763 MB 2.24 s, 1318 / 1866 MB 6.28 s, 1303 / 1417 MB 2.38 s, 1362 / 1568 MB 2.09 s, 627 / 749 MB
523 MB (private) 1,056,071 8.91 s, 1320 / 1913 MB 7.09 s, 1251 / 1808 MB 2.23 s, 1268 / 1844 MB 8.70 s, 1255 / 1288 MB 2.61 s, 1318 / 1372 MB 2.04 s, 161 / 195 MB
603 MB (private) 6,579,063 10.97 s, 2289 / 3081 MB 7.69 s, 1691 / 2375 MB 3.12 s, 1722 / 2450 MB 9.36 s, 1695 / 1776 MB 3.38 s, 1742 / 1899 MB 2.90 s, 643 / 749 MB
787 MB (private) 16,232,582 17.20 s, 4304 / 5811 MB 11.48 s, 2811 / 3884 MB 5.36 s, 2893 / 4063 MB 13.40 s, 2815 / 3100 MB 5.53 s, 2931 / 3336 MB 5.10 s, 1544 / 1837 MB
  • Memory: the paged strict parse takes the peak down by the size of the file on every model (−10% to −29% of peak; 787 MB model: 3884 → 3100 MB peak on one thread). Steady memory is unchanged. The lazy open holds 95–155 bytes per instance and nothing else (1.7–2.6x file size on eleven models, 0.3x on the 279 MB and 523 MB ones with few, large instances, 1.1x on the 603 MB one).
  • Time: the paged strict parse costs +16–24% on one thread and +3–17% on 12 threads against the in-memory buffer. That is the price of reading through pages today, and all of it is in page fetches and the cursor check; nothing in the parse itself distinguishes the two readers any more.
  • The strict parse, one thread, is 27–40% faster than v0.9.0 head on twelve models (21% and 20% on the two dominated by very large instances); 12 threads a further 2.0–3.2x.

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 char* to the whole file, so making the paged reader the default would have meant giving them up. Now the paged reader runs every path, and the remaining decision is a number: is +16–24% single thread (+3–17% on 12 threads) worth −1x file size of peak memory? My reading of the table is yes above some file size (say when the file exceeds a fraction of physical memory), and no for a 10 MB file where 0.16 vs 0.19 s and 71 vs 64 MB peak are both noise. A size-based default with the in-memory buffer as the small-file fast path is what I would propose; the alternative, closing the gap in the paged reader itself (larger pages, readahead into the worker's cache, mmap as the page source), is the work that would make the switch unconditional. Either way commit 11 is opt in here so you can decide it separately.

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.

@aothms

aothms commented Sep 13, 2026

Copy link
Copy Markdown
Member

The anomaly you were worried about was real

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.

keys of any other length (only invalid files) go to a std::map<std::string, ...>

Just drop the other map. I think it's an ok requirement that only valid guids can be looked up.

Materialising one instance on demand has to resolve that instance's references from its own slots

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?

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

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 token lexer::next() but passing a lambda as a consumer to the token stream so that more can be inlined - if that helps. The C++ template mechanism is flexible enough to realise what you are suggesting without two fully distinct lexers.

Moult and others added 11 commits September 13, 2026 19:54
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
@Moult

Moult commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

(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, ensure_loaded() seeks the paged reader to the offset and runs the same load_attributes() the strict parse runs, with inverse registration off because the index already has them, then resolves that instance's references. So it is what lazy meant in earlier versions: offsets, inverses captured while streaming, attributes from disk on demand. Not "skip the second pass". A modified instance is materialised first, so writing works.

In-place references, and your peak-memory question. Before this branch, when the parser met #12 in an attribute it could not store a pointer (the instance may be defined further down), so it left the slot empty and appended a note to a side table: instance, attribute index, referenced name. A second pass after the whole file walked that table and filled the slots. The table (1.66 M entries, ~64 MB on TXG) existed at the same time as every instance and was freed before open() returned, so it set the high-water mark of opening. Now the name goes into the slot as a placeholder and the second pass walks the instances' slots instead; no side table. Two effects: the high-water mark of opening drops by that table, and aggregates of references are built at their final size rather than copied out of a fatter temporary, which is why steady memory after opening also drops 7–9%.

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). spf_lexer gets scan_attributes(sink), the consumer-driven counterpart of next() you suggested: from just past ( to just past ;, it walks the bytes span by span with the SWAR helpers, decodes nothing, reports each #reference with its attribute index and the first attribute's raw text when it is a string, and goes through the same skip_whitespace() / skip_comment() as next(). Instance headers are read as tokens by one for_each_instance_header() that the parallel chunker and the lazy index now share, so both resolve declarations, log unknown types and collect bypassed instances identically. file_reader::span() hands out the bytes at the cursor that are contiguous in memory (rest of buffer, or rest of the current page), so the same code runs over pages and over a buffer. The scan checks the same malformations the scanner did and falls back to the full parse; there is a unit test for the corner cases (doubled quotes, comments inside attributes, nested lists, binaries, a lone slash, a semicolon inside an instance, unterminated input).

Making the tokenizer faster instead. Also done, and you were right that it is where the time was. callgrind on the shared tokenizer showed SWAR::has_special_char and eq_mask compiled as calls (one per 8 bytes), paged_file_impl::size() out of line behind every eof()/remaining(), and the cursor's page-cache check not inlined into peek() because it shared a function with the page fetch. Commit 12 fixes those three. Effect on TXG, single thread: tokenizer 196 → 204 MB/s in memory, 136 → 193 MB/s through pages; strict parse through pages 1.15 → 0.97 s against 0.93 s in memory. Across the fourteen models the paged tokenizer now runs at 87–100% of the in-memory speed (66–77% before, 17–21% before the cursor cache), and the strict parse through pages costs +0–6% on one thread and −6% to +1% on 12 threads, with peak memory down by the file size. That is the streaming answer: the paged reader can be the default without giving anything up, and commit 10 is the opt-in to try it.

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 create_entity, lookups are read-only, so it was not racier than the hash map; but materialising lazy instances from several threads is unsafe regardless of the map, which I should have said. The cost of leaving it out, same code with and without (branch open-perf-dense-ids):

model mode with without (this PR) cost
TXG 58 MB strict, 1 thread 0.99 s, 260 MB 1.04 s, 298 MB +5%, +38 MB
210_King 147 MB strict, 1 thread 2.70 s, 630 MB 2.80 s, 684 MB +4%, +54 MB
210_King strict, 12 threads 1.15 s 1.28 s +11%
OKgate22 231 MB strict, 1 thread 3.59 s, 988 MB 5.18 s, 1094 MB +44%, +106 MB
OKgate22 strict, 12 threads 1.61 s 2.06 s +28%
OKgate22 lazy 1.80 s 2.65 s +47%

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 begin() of one temporary string and end() of another, undefined behaviour that occasionally returned garbage. With every log write echoed as it happens, none of the flagged runs wrote anything. Fixed; zero messages on every run since.

Before and after, every model (C++ first, then Python; percentages against v0.9.0 head in the same row):

model v0.9.0 head, 1 thread this branch, 1 thread this branch, 12 threads this branch, paged, 12 threads this branch, lazy
TXG 58 MB 1.39 s, 357 MB (peak 463) 1.06 s (-24%), 283 MB (-21%) 0.46 s (-67%), peak 389 MB 0.46 s, peak 383 MB (-17%) 0.49 s, 141 MB (-61%)
210_King 147 MB 3.77 s, 877 MB (peak 1138) 2.77 s (-27%), 669 MB (-24%) 1.24 s (-67%), peak 915 MB 1.25 s, peak 819 MB (-28%) 1.42 s, 370 MB (-58%)
OKgate22 231 MB 6.21 s, 1364 MB (peak 1743) 5.27 s (-15%), 1079 MB (-21%) 2.05 s (-67%), peak 1422 MB 2.03 s, peak 1238 MB (-29%) 2.58 s, 533 MB (-61%)
10 MB (private) 0.23 s, 61 MB (peak 90) 0.19 s (-17%), 50 MB (-18%) 0.09 s (-61%), peak 83 MB 0.09 s, peak 88 MB (-2%) 0.09 s, 33 MB (-46%)
74 MB (private) 1.62 s, 444 MB (peak 573) 1.23 s (-24%), 357 MB (-20%) 0.51 s (-69%), peak 497 MB 0.50 s, peak 474 MB (-17%) 0.53 s, 158 MB (-64%)
81 MB (private) 1.77 s, 443 MB (peak 584) 1.35 s (-24%), 348 MB (-21%) 0.53 s (-70%), peak 496 MB 0.52 s, peak 466 MB (-20%) 0.61 s, 204 MB (-54%)
107 MB (private) 2.42 s, 581 MB (peak 792) 1.79 s (-26%), 458 MB (-21%) 0.77 s (-68%), peak 632 MB 0.74 s, peak 577 MB (-27%) 0.88 s, 279 MB (-52%)
249 MB (private) 5.62 s, 1335 MB (peak 1752) 4.38 s (-22%), 1024 MB (-23%) 1.98 s (-65%), peak 1428 MB 1.90 s, peak 1231 MB (-30%) 2.05 s, 551 MB (-59%)
279 MB (private) 4.81 s, 720 MB (peak 1044) 3.90 s (-19%), 680 MB (-6%) 1.22 s (-75%), peak 1016 MB 1.15 s, peak 789 MB (-24%) 0.96 s, 96 MB (-87%)
315 MB (private) 7.55 s, 1791 MB (peak 2333) 5.82 s (-23%), 1400 MB (-22%) 2.38 s (-68%), peak 1895 MB 2.29 s, peak 1629 MB (-30%) 2.51 s, 721 MB (-60%)
349 MB (private) 7.79 s, 1913 MB (peak 2551) 5.98 s (-23%), 1523 MB (-20%) 2.52 s (-68%), peak 2042 MB 2.49 s, peak 1744 MB (-32%) 2.80 s, 851 MB (-56%)
523 MB (private) 8.91 s, 1320 MB (peak 1913) 7.21 s (-19%), 1251 MB (-5%) 2.24 s (-75%), peak 1844 MB 2.13 s, peak 1373 MB (-28%) 1.81 s, 161 MB (-88%)
603 MB (private) 10.97 s, 2289 MB (peak 3081) 8.50 s (-23%), 1921 MB (-16%) 3.35 s (-69%), peak 2656 MB 3.25 s, peak 2105 MB (-32%) 3.55 s, 872 MB (-62%)
787 MB (private) 17.20 s, 4304 MB (peak 5811) 13.06 s (-24%), 3351 MB (-22%) 5.83 s (-66%), peak 4554 MB 5.64 s, peak 3819 MB (-34%) 6.61 s, 2083 MB (-52%)
model v0.8.5 v0.9.0 head this branch, default this branch, lazy
TXG 58 MB 2.81 s, 10.6x file size 1.45 s, 6.2x file size 0.47 s, 5.1x file size (-68%) 0.48 s, 2.5x file size (-67%)
210_King 147 MB 7.14 s, 11.1x file size 3.87 s, 6.0x file size 1.23 s, 4.6x file size (-68%) 1.40 s, 2.5x file size (-64%)
OKgate22 231 MB 11.44 s, 10.4x file size 6.46 s, 5.9x file size 2.00 s, 4.7x file size (-69%) 2.57 s, 2.3x file size (-60%)
10 MB (private) 0.50 s, 12.6x file size 0.25 s, 6.5x file size 0.09 s, 5.7x file size (-64%) 0.10 s, 3.6x file size (-60%)
74 MB (private) 3.56 s, 10.6x file size 1.68 s, 6.1x file size 0.51 s, 5.2x file size (-70%) 0.54 s, 2.2x file size (-68%)
81 MB (private) 3.57 s, 10.4x file size 1.82 s, 5.5x file size 0.52 s, 4.6x file size (-71%) 0.60 s, 2.5x file size (-67%)
107 MB (private) 5.16 s, 11.4x file size 2.49 s, 5.5x file size 0.76 s, 4.3x file size (-69%) 0.89 s, 2.6x file size (-64%)
249 MB (private) 11.93 s, 9.8x file size 5.78 s, 5.4x file size 1.97 s, 4.3x file size (-66%) 2.04 s, 2.2x file size (-65%)
279 MB (private) 9.65 s, 3.2x file size 5.13 s, 2.6x file size 1.20 s, 2.5x file size (-77%) 0.96 s, 0.4x file size (-81%)
315 MB (private) 14.74 s, 10.8x file size 7.82 s, 5.8x file size 2.30 s, 4.6x file size (-71%) 2.49 s, 2.3x file size (-68%)
349 MB (private) 16.52 s, 11.1x file size 8.37 s, 5.5x file size 2.43 s, 4.4x file size (-71%) 2.76 s, 2.4x file size (-67%)
523 MB (private) 18.01 s, 3.1x file size 9.18 s, 2.5x file size 2.23 s, 2.4x file size (-76%) 1.78 s, 0.3x file size (-81%)
603 MB (private) 22.52 s, 6.5x file size 11.37 s, 3.9x file size 3.37 s, 3.2x file size (-70%) 3.51 s, 1.4x file size (-69%)
787 MB (private) 36.95 s, 11.5x file size 18.12 s, 5.5x file size 5.71 s, 4.3x file size (-68%) 6.54 s, 2.7x file size (-64%)

Parallel. Unchanged in design; it now runs on the shared header loop and each worker has its own paged reader.

@aothms

aothms commented Sep 13, 2026

Copy link
Copy Markdown
Member

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, ensure_loaded() seeks the paged reader to the offset and runs the same load_attributes() the strict parse runs, with inverse registration off because the index already has them, then resolves that instance's references. So it is what lazy meant in earlier versions: offsets, inverses captured while streaming, attributes from disk on demand. Not "skip the second pass". A modified instance is materialised first, so writing works.

Clear. I don't understand how these are later unified with correct ordering.

struct unresolved_reference_list {
    std::vector<uint32_t> names;
    uint64_t file_offset;
};
struct unresolved_reference_list_list {
    std::vector<std::vector<uint32_t>> names;
    uint64_t file_offset;
};

How is this correctly (in correct order) interleaved with simple type refs (inline entity type and arglist) and entity instance refs (numeric ref).

@Moult

Moult commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

(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 set_direct_attribute() a select-typed aggregate as a std::vector<reference_or_simple_type> in file order, each element either an instance_reference (a #name) or an express::base (an inline typed value such as IFCPARAMETERVALUE(0.), already built as a simple-type instance). That type predates this branch (storage.h#L140-L141). The in-place commit only decides where that ordered vector goes (parse.cpp#L966-L1004):

  • every element is a #nameunresolved_reference_list{names} in the slot, names in the vector's order (L970-L971);
  • every element is an inline typed value → the slot gets std::vector<express::base> directly, there is nothing to resolve (L972-L973);
  • anything mixed, e.g. (IFCPARAMETERVALUE(0.), #1)record(value): the whole ordered vector goes to the side table exactly as before this branch, and resolve_table_references() turns it into the aggregate in that order, interleaving resolved names and inline instances element by element as it always did (parse.cpp#L2644-L2683). In lazy mode that table is lazy_mixed_references_, drained after each materialisation (L2775-L2778).

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 $ if the name is missing; a list placeholder becomes std::vector<express::base> in the stored order, a missing name dropped from it, which is what the table path did for lists too.

Inline typed values that themselves contain references (the IfcPropertySetDefinitionSet case) are simple-type instances with their own storage, so their references land in their own slots (the diversion comment at L950-L952) and the same slot walk runs over them: in the strict parse over every simple-type instance read (L3334), in lazy mode over the ones read during that materialisation (L2773).

Covered by the fixture in the test (test_ifcopenshell_parse.cpp#L385-L426): #5=IFCTRIMMEDCURVE(#4,(IFCPARAMETERVALUE(0.),#1),(IFCPARAMETERVALUE(1.)),...) checks the mixed list comes back as [IfcParameterValue, #1] in that order and the all-inline list as [IfcParameterValue]; #10=IFCBSPLINESURFACEWITHKNOTS(...,((#1,#2),(#3,#999)),...) checks the nested list with a missing name; and the lazy equality test compares every instance's to_string() against the strict parse on the same file, so both paths produce the same text.

@aothms

aothms commented Sep 13, 2026

Copy link
Copy Markdown
Member

Done, the byte scanner is deleted (commit 11).

I still see several while() loops that loop over raw file bytes

while (true) {

while (true) {

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).

@Moult

Moult commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

The fresh series is up as #9492: your order, one tokenizer (the index is next<index_tokens>(), no byte scanner), references in the slots via the existing reference_or_simple_type, lazy offsets in a lazy-only vector, parallel with a comment-aware split scan. 1787 lines, 1340 outside tests. Two things worth knowing came out of it: a quote-counting byte scan ends a string early at \S\', which the shared decoder does not, and comments in DATA no longer force the serial path. Per-model numbers on all fourteen files are in the PR body. (AI-written, not verified by Dion.)

@aothms

aothms commented Sep 14, 2026

Copy link
Copy Markdown
Member

a quote-counting byte scan ends a string early at \S'

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.

@Moult

Moult commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

(Dion here now)

I reckon we can close this one because it's completely superseded?

@aothms aothms closed this Sep 15, 2026
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