Repository navigation
Conversation
Reading a text CIF file spent most of its time tokenizing `atom_site` in Python: one interpreted loop step per value, then `np.asarray()` on lists of `str`. The tokenizer now runs in Rust and writes the values directly into NumPy unicode arrays. Block and category splitting move as well, so the rules for empty lines and category names exist only once. The output is unchanged, except that text with lone surrogates now raises a `DeserializationError`.
| } | ||
|
|
||
| /// Borrow the text of a Python string. | ||
| fn to_str<'a>(text: &'a Bound<'_, PyString>) -> PyResult<&'a str> { |
There was a problem hiding this comment.
This is the one deliberate change in behaviour. PyString::to_str() needs valid UTF-8, and a lone surrogate (for example from a handle opened with errors="surrogateescape") is not valid UTF-8. main parses such text, and this PR raises a DeserializationError that says why. CIFFile.read(path) opens files in strict mode, so this only affects callers who pass their own text handle. Exact support would need a second path over bytes. I think the clear error is enough, but I can add that path if you prefer.
| )); | ||
| } | ||
|
|
||
| // Second pass: write each value directly into its array, |
There was a problem hiding this comment.
Two passes over the values. The first pass counts the values and finds the widest one per column, and the second writes each value straight into its array. One pass is about 20% faster on atom_site (3J3Q: 0.92 s instead of 1.14 s, measured before the rebase), but it must keep all values (16 bytes each as &str) until the widths are known, so peak memory was 3.07 GB instead of 2.46 GB. If you prefer speed, the change back is small.
| } | ||
|
|
||
| /// Equivalent to Python's ``str.splitlines()``. | ||
| fn split_lines(text: &str) -> Vec<&str> { |
There was a problem hiding this comment.
str::lines() would be shorter, but it splits only at \n and \r\n. Python's str.splitlines() also splits at \r, \x0b, \x0c, \x1c–\x1e, \x85,
and
. Block splitting reads the raw file text, so this function copies splitlines() exactly to keep the current behaviour.
| } | ||
|
|
||
| /// The error Python raises for an index beyond the end of a list. | ||
| fn index_error() -> PyErr { |
There was a problem hiding this comment.
This copies Python's IndexError("list index out of range"), so that broken input fails exactly as on main. A DeserializationError that names the problem ("category has no lines", "key has no '.'") would be clearer. CIFBlock.__getitem__ wraps every error anyway, so only direct callers of CIFCategory.deserialize() would see a different type. Happy to switch.
| let width = value_part.chars().count().max(1); | ||
| let mut buffer = vec![0; width]; | ||
| write_code_points(&mut buffer, value_part); | ||
| // A repeated key is resolved by the `dict()` call on the Python side |
There was a problem hiding this comment.
Rust returns pairs, and dict() on the Python side handles a repeated key: the key keeps its first position and gets the last value. That is the same result as the old category_dict[key] = ... assignment.
| import biotite.structure as struc | ||
| import biotite.structure.io.pdb as pdb | ||
| import biotite.structure.io.pdbx as pdbx | ||
| from biotite.rust.structure.io.pdbx import split_one_line |
There was a problem hiding this comment.
split_one_line stays a #[pyfunction] only so that this test keeps all its cases. The case '"\t\n"' cannot go through CIFCategory.deserialize(), because the \n would split the line.
Closes #962.
Reading a text CIF file spends most of its time on tokenizing
atom_sitein Python: a loop over each value, thennp.asarray()on lists ofstr(see the measurements in #962). This PR moves the CIF tokenizer to Rust. Rust splits the file into blocks and the blocks into categories, and turns category text directly into NumPy unicode arrays. The public API and the lazy loading do not change.Performance
Median seconds, release build, both branches on
49273958. Apple M4 Max, macOS 27.0.1, Python 3.12.0. Medians of 20 runs (files under 2 MB), 5 runs (under 120 MB) or 3 runs (3J3Q).atom_siterowsatom_sitedeserialize,main→ PRget_structure(model=1),main→ PRPeak memory when deserializing the 3J3Q
atom_site(/usr/bin/time -l): 4.14 GB → 2.48 GB.Behaviour
The output equals
mainin 235,044 comparisons, with 0 differences. These include every category, block split and file split of the 34 CIF files undertests/and of the 5 entries above, 40,000 random malformed category texts (half with indented lines), and every code point up to U+3100 except surrogates. The error cases (64,195) raise the same error type with the same message.One deliberate change: text with lone surrogates now raises a
DeserializationError(details on the line).Trade-offs
str.splitlines(), whitespace followsstr.isspace(), and broken input raises the same errors. I did not measure the cost separately, but it is a quick check per character.atom_site. The first pass counts and measures the values, and the second pass writes them directly into the arrays. One pass was faster (3J3Q: 0.92 s instead of 1.14 s, measured before the rebase), but it kept every value until all widths were known, which needed about 0.6 GB more.What is left, and what it would take
For 3J3Q, parsing now takes about 1.6 s of the 3.4 s.
get_structure()spends most of the rest on converting string columns to numbers: converting one float column takes 0.22 s.CIFColumnholds strings today. It would need a typed fast path inget_structure(), or a change to whatCIFCategoryreturns.CIFFileandCIFBlockstore unparsed elements.atom_site, if speed matters more than memory.As a reference point, #962 measured BinaryCIF at 0.23 s and gemmi at 1.19 s for 3J3Q.
This PR keeps the current handling of blank lines inside
;text values (see #962). If you want, I can change that in a separate PR.Benchmark script