Skip to content

Efficient ASCII parsing#4

Merged
Modertool999 merged 9 commits into
mainfrom
fast-ascii-parse
Jul 6, 2026
Merged

Efficient ASCII parsing#4
Modertool999 merged 9 commits into
mainfrom
fast-ascii-parse

Conversation

@sampsyo

@sampsyo sampsyo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

This is TRULY not the most important thing to be doing right now, but I have a kind of special interest in writing efficient parsers for super-duper simple text formats in the vein of ASCII AIGER. So I couldn't resist trying out a few standard tricks. I again don't think our ASCII parsing speed is all that important for this project, but perhaps some of these approaches will be interesting/educational for @Modertool999 nonetheless.

This PR is best reviewed commit by commit; I tried to make each change standalone and digestible. Please see the commit messages associated with each one for more detail, but the main techniques here are:

  • A one-pass, byte-based parser for text-file lines containing integers.
  • Because the AIGER literal namespace is dense, replace the HashMap in the relevant data structure with a dense array.

The final commit (e329fb8) contains one possibly questionable change. Please let me know if you think this is an acceptable detail to expose!


Here are some benchmarking results, all from gorgonzola. I used this Hyperfine command to run on a 1.2 MB ASCII AIGER file, converted from a binary file that @ekiwi shared:

hyperfine -N -w3 './target/release/aig ../WT_UB16_KS_64x64_noX_multgen.aag --parse-only'

Here are the results for various checkpoints:

  • Original (cde5d7c): 91.6 ms ± 16.6 ms
  • Efficient line parser (ec8c1f9): 22.9 ms ± 0.6 ms
  • Dense mapping (7d08ae6): 8.3 ms ± 1.4 ms
  • Minor mapping footprint tweaks (e329fb8): 8.1 ms ± 0.5 ms
    (Let's call that no significant change from the previous point. But it was fun anyway?)

sampsyo added 9 commits July 2, 2026 09:06
Just an additional CLI flag for running the parser only, which I'll use
to benchmark stuff while I'm focusing on that piece. I also slightly
refactored the usage string. I'm considering all this somewhat
temporary; eventually, this `main` will move to a more proper CLI
parsing situation.
Because we know how many latches and outputs we will need, using
`Vec::with_capacity` instead of `Vec::new` avoids reallocations when we
eventually `push` that many elements into the vector.
This is a standalone utility (not yet integrated into the parsers) for
handling text-file lines consisting of lists of integers. It has two
main advantages over what the ASCII AIGER parser currently does:

* It parses each line in a single pass, from left to right, without
  allocating anything. So we save the time creating and destroying the
  `Vec`s required for `String::split_whitespace`.
* It operates on bytes instead of Rust's UTF-8-encoded `String`s.
  Because we're only looking for ASCII numerals and whitespace, the full
  generality of Unicode string handling isn't helping us much.
We replace `read_one_number_line`, `read_latch_line`, and
`read_and_line` with calls to our new `LineParser`. To make this
convenient, I added a wrapper that also takes care of advancing through
the text-file lines emitted from a `BufRead`.
Oddly enough, the binary AIGER format still has some text lines in it!
So we reuse our new efficient parser for ASCII-encoded integers in text
files for that part of the binary parser too.
The AIGER header (used for both binary and text formats) is *also* a
whitespace-separated list, consisting mostly of ASCII integers, so let's
use our new one-pass processing approach for that as well.
`Literals` maps AIGER literals integers to our internal node IDs. We
expect the space of AIGER literals to be dense: i.e., there are not many
gaps in the "number line" of these integers. For dense data like that, a
hash table doesn't buy us much space, and it costs time. So we can
instead try a dense mapping based on a `Vec`.

This doesn't yet try to guess the right maximum literal, so we just
resize on demand.
Because our table only stores (and looks up) the "positive" literals, we
were actually only using half the allotted space: every other "slot" in
our array was unused. Now, we more carefully allocate one slot per
*variable*, rather than one slot per literal.

This also calculates the amount of space we need up front. The header
tells us the maximum number of variables, so we need a vector contains
that many slots. This way, we never need to resize.
We already have a way to signal "not a valid node," so maybe we can use
that in our literals table. This means that our literals table is half
as big, because each entry is 32 bits instead of 64 bits (the
1-bit discriminator + 32-bit value = 64 bits, unfortunately).

This is my most dubious suggestion of this optimization effort! It
required me making the reserved node ID value public where it was
previously an internal implementation detail. We should consider whether
this is a bad idea!
@Modertool999 Modertool999 merged commit a703089 into main Jul 6, 2026
1 check passed
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