001 Notes

inih Optimization: Simpler Scans Beat Fancy Tricks

0. Abstract

This note looks at a small parser with a useful optimization lesson:

the best speedups did not come from adding a lot more machinery
they came from removing avoidable work that the baseline kept paying for

The measured inih changes fall into four buckets:

AreaMain changeBest role in the final result
String inputmemchr() plus memcpy() instead of byte-at-a-time copyingremoves avoidable front-end work
Parser scanexplicit ASCII whitespace and delimiter testsbiggest parser win
SIMDAVX2 only for a narrow delimiter/comment scanuseful on long lines, not the main story
C++ wrappersorted flat storage instead of tree storagehuge enumeration win

On the measured Ryzen 9 5900HX system, the combined result versus the baseline was:

  • C parser, string mode: 2.572x
  • C parser, file mode: 2.068x
  • C++ parse: 1.331x
  • C++ lookup: 1.176x
  • C++ enumerate: 7.414x

The strongest conclusion is narrower than “SIMD made it fast”:

bulk copy the line input
use the exact character tests the format needs
store parsed key/value data in the shape readers actually use

1. Baseline And Choke Points

The baseline parser had three avoidable costs in hot paths:

  • string parsing copied input one byte at a time until \n
  • leading and trailing whitespace cleanup repeatedly called isspace()
  • delimiter and comment scans repeatedly called strchr() on tiny fixed sets

The C++ wrapper had a different kind of cost. It stored all values in a std::map<std::string, std::string>, then paid tree-walk and rescanning costs again for lookups, section enumeration, and key enumeration.

That led to the right optimization order:

  1. remove work before vectorizing anything;
  2. keep the parser scalar when scalar is already enough;
  3. fix the C++ data layout instead of micro-tuning tree access.

2. Bulk Line Reads Beat Byte-At-A-Time Copying

Old Shape

The string reader copied one character at a time until it found a newline:

while (num > 1 && ctx_num_left != 0) {
    c = *ctx_ptr++;
    ctx_num_left--;
    *strp++ = c;
    if (c == '\n')
        break;
    num--;
}

That loop is correct, but it forces every line to pay for per-byte control flow before the real parsing even begins.

New Shape

The faster path uses one bounded newline search and one bulk copy:

max_copy = (size_t)num - 1;
if (max_copy > ctx_num_left)
    max_copy = ctx_num_left;
newline = (const char*)memchr(ctx_ptr, '\n', max_copy);
copy_len = newline ? (size_t)(newline - ctx_ptr) + 1 : max_copy;

memcpy(str, ctx_ptr, copy_len);
str[copy_len] = '\0';

Measured Result

Isolated on comments-16m.ini, warm cache, 15 iterations:

  • string parse: 48.965 ms -> 37.973 ms, 1.289x
  • throughput: 326.77 -> 421.36 MiB/s

Broader string-mode stability results:

DatasetBeforeAfterSpeedup
comments-1m3.047 ms2.403 ms1.268x
comments-16m50.175 ms39.473 ms1.271x
long-1m5.127 ms4.366 ms1.174x
long-16m83.372 ms71.810 ms1.161x

The instruction count dropped from 15.67B to 12.33B, and branches dropped from 3.60B to 2.85B.

That is exactly the kind of gain worth keeping: simpler code, less work, and a clear speedup on the workload the function actually serves.

3. Simpler Character Tests Were The Biggest Parser Win

Old Shape

The baseline parser leaned on general libc helpers:

while (end > s && isspace((unsigned char)(*--end)))
    *end = '\0';

while (*s && (!chars || !strchr(chars, *s)) &&
       !(was_space && strchr(INI_INLINE_COMMENT_PREFIXES, *s))) {
    was_space = isspace((unsigned char)(*s));
    s++;
}

For an INI parser, those helpers are broader than the problem. The parser only needs a small ASCII whitespace set and very short delimiter/comment-prefix sets.

New Shape

The faster version uses explicit format-specific checks:

static int ini_isspace(unsigned char c)
{
    return c == ' ' || c == '\t' || c == '\n' || c == '\r' ||
           c == '\f' || c == '\v';
}

static int ini_char_in(const char* chars, char c)
{
    if (!chars)
        return 0;
    while (*chars) {
        if (*chars++ == c)
            return 1;
    }
    return 0;
}

Compatibility Boundary

This changes whitespace classification from “whatever the active C locale says” to explicit ASCII whitespace. That is usually the right tradeoff for config files, but it is still the one behavior boundary worth checking before any upstream proposal.

Measured Result

On comments-16m.ini, warm cache, 15 iterations:

  • string parse: 39.262 ms -> 31.847 ms, 1.233x
  • throughput: 407.52 -> 502.40 MiB/s

Broader stability results versus the already-improved bulk-copy step:

DatasetModeBeforeAfterSpeedup
comments-1mstring warm2.403 ms1.106 ms2.173x
comments-1mfile warm2.869 ms1.433 ms2.002x
comments-16mstring warm39.473 ms17.932 ms2.201x
comments-16mfile warm48.753 ms23.546 ms2.071x
long-1mstring warm4.366 ms2.409 ms1.812x
long-16mfile warm75.565 ms41.526 ms1.820x

The most important counters moved in the intended direction:

  • instructions: 12.30B -> 8.87B
  • branches: 2.86B -> 1.68B
  • cycles: 5.37B -> 4.30B

Branch misses rose slightly in absolute terms, but the parser still won hard on wall time because it executed far fewer instructions and branches overall.

4. AVX2 Helped, But It Was Not The Main Story

The AVX2 path was deliberately narrow. It only accelerates the parser’s “find the first interesting byte” work for simple delimiter sets such as:

  • ] on section lines
  • = or : on name/value lines
  • inline ; comments after whitespace

That is the right scope. A full “vectorize the parser” rewrite would add much more complexity than the workload justifies.

Measured Result

SIMD-only geomean versus baseline:

  • parser string: 1.407x
  • parser file: 1.414x

Best 128 MiB string-mode rows:

DatasetBaselineSIMD-onlySpeedup
long186.49 MiB/s349.71 MiB/s1.88x
comments312.95 MiB/s539.66 MiB/s1.72x

Those are real wins, but the broader parser story stayed scalar-first:

  • scalar-only geomean: 2.750x string, 2.138x file
  • final combined geomean: 2.572x string, 2.068x file

So AVX2 belongs in the final build as a selective fast path, not as the main explanation for the speedup.

5. Flat Storage Made The C++ Wrapper Behave Like A Reader

The biggest C++ problem was data layout. The baseline wrapper used a tree for everything, even though the dominant usage pattern is:

  1. parse once;
  2. look up values many times;
  3. enumerate sections and keys.

That pattern wants sorted contiguous storage, not one heap node per key.

New Shape

The faster wrapper stores Entry { key, value } objects in a vector, sorts once, merges duplicates, then serves reads with binary search and sequential range walks.

It also adds a GetView() path so a lookup can return std::string_view instead of always allocating a new string copy.

Measured Result

Flat-reader geomean versus baseline:

  • C++ parse: 1.037x
  • C++ lookup: 1.171x
  • C++ enumerate: 7.203x

Representative 1 MiB enumeration rows:

DatasetBaselineFlat storageSpeedup
compact138.66 ms1.32 ms105.05x
multiline104.47 ms1.12 ms93.28x
duplicate78.08 ms0.68 ms114.82x

This is a good example of a cache-friendly optimization that earns its keep. It did not add a framework. It just stored the data in the shape the API was already asking for.

6. Combined Results

Measured geomean versus the baseline:

VariantParser stringParser fileC++ parseC++ lookupC++ enumerate
baseline1.000x1.000x1.000x1.000x1.000x
scalar-only2.750x2.138x0.909x0.702x0.666x
SIMD-only1.407x1.414x1.117x1.012x1.061x
flat reader only1.304x1.342x1.037x1.171x7.203x
final combined2.572x2.068x1.331x1.176x7.414x

Representative final parser throughput at 128 MiB:

DatasetBaselineFinal
comments312.95 MiB/s767.08 MiB/s
long186.49 MiB/s563.58 MiB/s
compact128.80 MiB/s238.47 MiB/s

Representative final C++ rows at 1 MiB:

MetricBaselineFinal
compact parse49.95 MiB/s51.13 MiB/s
compact lookup7,760,030/s7,714,430/s
compact enumerate138.66 ms1.36 ms
duplicate lookup5,351,000/s7,231,130/s
duplicate enumerate78.08 ms0.70 ms

7. Takeaway

The most valuable inih optimizations were not the fanciest ones.

They were:

  • bulk-copy line input instead of copying byte-by-byte;
  • use exact ASCII tests instead of repeated general-purpose libc helpers;
  • store parsed C++ data as sorted contiguous entries instead of a tree.

The SIMD path still helped where long lines made the search wide enough to justify vectorization. But the durable performance story was simpler:

the parser got faster because it stopped doing unnecessary work
the wrapper got faster because it stopped storing data in a reader-hostile shape

Comments