Skip to content

perf: profile the write path; buffer per-atom output (byte-identical) - #39

Merged
jameskermode merged 1 commit into
masterfrom
perf/write-buffer
Jun 10, 2026
Merged

jameskermode merged 1 commit into
masterfrom
perf/write-buffer

Conversation

@jameskermode

@jameskermode jameskermode commented Jun 9, 2026 •

Copy link
Copy Markdown
Member

Profiling the write path (the ask)

We'd optimized reads but never looked at writes. Benchmark (benchmarks/bench_write.py, new), 200k-atom frame:

writer time vs ours
our C writer 210 ms —
ASE built-in extxyz 510 ms 2.4× slower
ASE cextxyz plugin 224 ms ≈ ours + Atoms→Frame
pure-Python (np.savetxt) 522 ms 2.5× slower
extxyz-ng (Rust)¹ 23.9 ms at 20k vs our 15.5 ms 1.5× slower

¹ measured on a species+pos frame; extxyz-ng can't read the forces+pbc-bool fixtures, so it's n/a in the table for those.

So we already beat ASE (2.4×) and extxyz-ng (1.5×) — but writing 200k atoms took ~266 ms vs ~33–50 ms to read. The per-atom loop did one fprintf per value (~15 per atom incl. separators/newline), each paying format-string parsing + a flockfile/funlockfile pair.

A C micro-benchmark (200k×6 floats) split the cost:

  • per-cell fprintf: 179 ms
  • snprintf → line buffer + fwrite: 157 ms (1.14×)
  • custom integer formatter → buffer: 90 ms (2×)

i.e. the bottleneck is the %.8f formatting itself, not I/O. The 2× lever needs a custom float formatter, which can't be byte-identical to printf (round-half-to-even — mismatches ~1 in 5M) without a fiddly correctly-rounded implementation. Given we already lead both competitors, we opted for the safe, byte-identical win only.

This change — buffering (byte-identical)

Build each atom line in a growable memory buffer with snprintf, fwrite it in blocks (flush at line boundaries). Same output bytes, far fewer locked stdio calls: ~1.26× on a 200k write (266 → 212 ms).

  • Byte-identical, verified old-vs-new across signs, -0.0, rounding edges, ints/bools/strings, multi-column, and a custom format_dict. Golden + round-trip + block-boundary tests lock it (tests/test_write_buffer.py).
  • format_dict override (Precision specification when writing to extxyz #22) path unchanged; extxyz_write_ll ABI unchanged (Fortran/CI unaffected).

Verification

  • pytest tests/ (49, incl. test_write_precision.py) + python/ase-extxyz/tests/ (38) green.
  • leaks --atExit = 0 on the read→write C driver (the buffer is one alloc/free).
  • benchmarks/bench_write.py reproduces the comparison (times extxyz-ng if EXTXYZ_NG_PYTHON is set).

🤖 Generated with Claude Code

Profiling the write path: our C writer already beats ASE's built-in extxyz
writer by ~2.5x and extxyz-ng by ~1.5x, but writing a 200k-atom frame took
~266 ms vs ~33-50 ms to read it. The cost is the per-atom output loop, which
did one fprintf per value (~15 per atom incl. separators/newline for a 7-column
frame) -- each paying format-string parsing and a flockfile/funlockfile pair.

A micro-benchmark showed the dominant cost is the %.8f float formatting itself,
not I/O: buffering alone is ~1.14x while a custom integer formatter would be ~2x
but cannot be byte-identical to printf's round-half-to-even (mismatches ~1 in 5M)
without a fiddly correctly-rounded formatter. So this lands the safe, byte-
identical win only: build each line in a growable memory buffer with snprintf and
fwrite it in blocks, flushing at line boundaries. Same output bytes, far fewer
locked stdio calls -- ~1.26x on a 200k write (266 -> 212 ms).

Output is byte-identical (verified old-vs-new across signs, -0.0, rounding edges,
ints/bools/strings, multi-column, and a custom format_dict); a golden + round-trip
test locks it. The format_dict override (#22) path is unchanged. New
benchmarks/bench_write.py reproduces the comparison vs ASE and extxyz-ng.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@20k

20k commented Jun 10, 2026

Copy link
Copy Markdown

Please don't ping me in your AI slop PRs thanks

@jameskermode

Copy link
Copy Markdown
Member Author

Please don't ping me in your AI slop PRs thanks

Apologies. It's not exactly slop, but I take your point. 20k was referring to the system size I was benchmarking on, an accidental collision with your username.

jameskermode added a commit that referenced this pull request Jun 10, 2026
Profiling (#39) showed the write path is bound by formatting the per-atom floats,
not I/O: buffering alone was 1.14x while a custom formatter promised ~2x. This
adds that formatter for the default "%16.8f", proven byte-identical to printf.

A finite double is |v| = m*2^e exactly, and 10^8 = 2^8*5^8, so
  v*10^8 = m*390625*2^(e+8)
is an exact rational. We round it to nearest (ties to even, matching printf's
default) with integer-only arithmetic -- a 128-bit shift + half-bit test, no
floating-point error -- then format the digits manually (no snprintf). It falls
back to snprintf for non-finite / |v|>=1e15, for any custom format_dict (#22),
and on compilers without __int128 (MSVC), so output is byte-identical everywhere.

new libextxyz/fast_format.{c,h}; wired into the #39 buffered per-atom loop only
when the float format is the default (fmt_f == NULL). Int/bool/string unchanged.

Result: 200k-atom write 210 -> 84 ms (~2.4x over #39, ~3x over the original
fprintf writer); now ~6x faster than ASE's writer and ~3x faster than extxyz-ng.

Validation: libextxyz/test_fmt_float.c checks fmt_default_f16_8 == snprintf over
60M+ doubles incl. dense sweeps across round-half-even ties, subnormals, +/-0.0,
and the fallback boundary (wired as a `meson test`); the naive round-half-up
formatter fails it ~1 in 5M, the exact one passes 0/66M. tests/test_write_float_
format.py validates byte-identity end-to-end through the writer (default fast path
== explicit %16.8f snprintf path) over 300k random + crafted-edge values. #39
golden/round-trip + #22 precision stay green; leaks 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jameskermode
jameskermode merged commit e05f0a4 into master Jun 10, 2026
26 checks passed
jameskermode added a commit that referenced this pull request Jun 10, 2026
Profiling (#39) showed the write path is bound by formatting the per-atom floats,
not I/O: buffering alone was 1.14x while a custom formatter promised ~2x. This
adds that formatter for the default "%16.8f", proven byte-identical to printf.

A finite double is |v| = m*2^e exactly, and 10^8 = 2^8*5^8, so
  v*10^8 = m*390625*2^(e+8)
is an exact rational. We round it to nearest (ties to even, matching printf's
default) with integer-only arithmetic -- a 128-bit shift + half-bit test, no
floating-point error -- then format the digits manually (no snprintf). It falls
back to snprintf for non-finite / |v|>=1e15, for any custom format_dict (#22),
and on compilers without __int128 (MSVC), so output is byte-identical everywhere.

new libextxyz/fast_format.{c,h}; wired into the #39 buffered per-atom loop only
when the float format is the default (fmt_f == NULL). Int/bool/string unchanged.

Result: 200k-atom write 210 -> 84 ms (~2.4x over #39, ~3x over the original
fprintf writer); now ~6x faster than ASE's writer and ~3x faster than extxyz-ng.

Validation: libextxyz/test_fmt_float.c checks fmt_default_f16_8 == snprintf over
60M+ doubles incl. dense sweeps across round-half-even ties, subnormals, +/-0.0,
and the fallback boundary (wired as a `meson test`); the naive round-half-up
formatter fails it ~1 in 5M, the exact one passes 0/66M. tests/test_write_float_
format.py validates byte-identity end-to-end through the writer (default fast path
== explicit %16.8f snprintf path) over 300k random + crafted-edge values. #39
golden/round-trip + #22 precision stay green; leaks 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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