Skip to content

feat!: FixedWidthParser 2.0 — bytes-only API, native library and Python package - #79

Merged
GabrielMarquezMatte merged 68 commits into
developfrom
feat/v2-api
Oct 25, 2026
Merged

GabrielMarquezMatte merged 68 commits into
developfrom
feat/v2-api

Conversation

@GabrielMarquezMatte

Copy link
Copy Markdown
Owner

Summary

The 2.0 release. The library now reads and writes bytes only and goes through one entry point, FixedWidth. On top of it sits a NativeAOT C library, with Python and C++ bindings over that. Settings live in options objects and FixedWidthContext, so later 2.x releases can add settings without another breaking change.

The full list of changes, and a 1.x → 2.0 migration table, is in the CHANGELOG.

Breaking changes

  • Bytes only. The char and UTF-8 twins of every reader, parser and writer are gone, and column positions are bytes. Text columns are decoded with an ASCII-compatible encoding, UTF-8 by default.
  • One entry point. Every FixedWidth method takes either a generated model or a FixedWidthLayout<T>. FixedWidthParser<T>, FixedWidthReader<T> and FixedWidthWriter<T> are removed.
  • Root namespace. User-facing types moved to FixedWidthParser. Plumbing that only generated code uses moved to FixedWidthParser.Infrastructure and is hidden from IntelliSense.
  • Options objects. FixedWidthReaderOptions / FixedWidthWriterOptions replace the positional provider, pool and buffer-size parameters, and FixedWidthParallelOptions is folded into FixedWidthReaderOptions.
  • Null format provider. null now means the invariant culture for every column type, not only double/float.
  • When a bad line throws. It now throws when the enumerator's Current is read, not from MoveNext.
  • PipeReader. Reading from a PipeReader is removed; use pipeReader.AsStream(), which is about 2x faster.
  • Target framework. net10.0 only. .NET 8 support ends on 10 November 2026.
  • Dependencies. csFastFloat is removed. Fast parsing now comes from in-house ports of ExcelReader's FastDouble/FastDecimal.

New

  • FixedWidthLayout<T>: build a layout from the attributes, or describe it in code with FixedWidthLayout.Build<T>(...). No reflection, so it works under trimming and NativeAOT.
  • Parallel aggregation: AggregateFileAsync splits a file into byte ranges, and AggregateAsync splits a stream into chunks. Line numbers in errors match a sequential read.
  • Errors and limits: FixedWidthParseException gives the line, column and raw value. OnParseError lets a caller skip bad lines. MaxLineLength bounds untrusted input.
  • More model shapes: ref struct models, nullable value-type columns, per-property converters, and the source-generated writer.
  • FixedWidthParser.Native (fixedwidth.h):
    • Layouts are described at run time.
    • Parallel reads produce a columnar table or Arrow C Data Interface arrays and streams.
    • Batched reads.
    • Writes are byte-identical whatever the thread count.
  • fixedwidth Python package: results as NumPy views, pyarrow, pandas or polars.
  • Header-only C++23 wrapper, with FixedWidthMapper<T>.
  • Release pipeline: a v* tag publishes the NuGet package, Python wheels to PyPI as fixedwidthparser (Windows x64, macOS arm64/x64, Linux glibc and musl on x64/arm64), and each platform's native library to the GitHub Release.

Performance

Measured on a synthetic 1,000,000-line B3 COTAHIST file (235 MiB) through Python, on a Ryzen 7 5700X (16 threads). Details are in docs/python-benchmarks.md.

Start of the perf work Now
fw.read, parallel 0.261 s 0.153 s
fw.read, 1 thread 0.920 s 0.593 s
fw.write (pyarrow), parallel 0.259 s 0.162 s

pandas.read_fwf takes 14.8 s on the same file.

What made the difference:

  • Digit-only dates and integers are parsed straight from the bytes.
  • Partitions parse directly into the final columns, so they aren't copied together afterwards.
  • Column buffers are sized once per partition.
  • Files are written with asynchronous I/O.

Testing

  • .NET: 472 tests pass locally (Windows).
  • Python: 35 tests pass. A wheel built locally installs into a clean venv, where the core tests pass without pyarrow/pandas/polars (what the musl job will run).
  • The NuGet package contains lib/net10.0 and the generator under analyzers/dotnet/cs.
  • CI on Linux, Windows and macOS (CI, Native bindings, CodeQL).

Before tagging 2.0.0

  • Review PublicAPI.Unshipped.txt (197 entries). After 2.0 ships, any change to these entries is breaking.
  • Add a PyPI pending trusted publisher: fixedwidthparser, GabrielMarquezMatte/FixedWidthParser, release.yml.
  • Tag v2.0.0-rc.1 first; a tag containing - is published as a prerelease.
  • After 2.0.0: move the unshipped API entries into PublicAPI.Shipped.txt, and re-enable package validation with baseline 2.0.0 (see the note in FixedWidthParser.csproj).

gabriel-matte-daemon and others added 30 commits October 15, 2026 14:38
2.0 keeps one parse path. The char parser, FixedWidthUtf8, FixedWidthByteReader,
Utf8FixedWidthParser, IUtf8FixedWidthModel and IUtf8FixedWidthConverter are gone:
there is one parser, one reader, one writer and one generated TryParse/TryFormat,
all over bytes. Column positions are bytes; text columns, dates parsed with a
format and types that only parse from characters are decoded with an
ASCII-compatible encoding, UTF-8 by default. The writer formats straight to the
stream, batched into one pooled buffer, and never splits a multi-byte character
when it truncates. The package targets net10.0 only.

Reads take FixedWidthReaderOptions (format provider, encoding, string pool,
buffer size, MaxLineLength, OnParseError) and the writer takes
FixedWidthWriterOptions (format provider, encoding, new line). Generated code
and IFixedWidthConverter<T> receive a FixedWidthContext by in reference, so a
later setting arrives as a property instead of a new parameter.

A line that fails to parse throws FixedWidthParseException, a FormatException
that names the line, the column, its position and its raw text, or says how
short the line is. The reader learns the column by parsing the failed line again
with a failure sink, so lines that parse pay nothing. OnParseError can skip the
line instead.

MaxLineLength bounds the line buffer on stream, async and pipe reads. A source
without line breaks used to double the buffer until memory ran out.

The generator targets netstandard2.0, reports FWP012 for a non-ASCII Padding or
TrimChar, and its tests now compile the generated code. The library tracks its
public API (PublicAPI.*.txt), validates the package against 1.1.1 with the
deliberate breaks listed in CompatibilitySuppressions.xml, and builds with
warnings as errors.
Packing runs package validation, so a removed or changed public member fails the
pull request instead of the release.
The README describes the byte-only reader and writer, the options, the errors,
encodings and the versioning rules. The changelog separates what shipped in
1.1.0 and 1.1.1 from 2.0, with a migration table. CONTRIBUTING explains the
public API tracking and package validation.
… doubles as scaled integers

IUtf8SpanFormattable columns format directly into the line's slice and are
aligned in place, with no char buffer, transcode or copy. The UTF-8 bytes are
kept when the encoding is UTF-8 or they are ASCII; otherwise the value goes
through the configured encoding as before.

A double with no Format, at most four decimals and a magnitude under 1e9 is
written as a scaled integer, byte-identical to double.TryFormat, for any
culture whose decimal separator is one ASCII character and negative sign is
"-". Left-aligned strings under UTF-8 transcode straight into the slice.
Extract was over MA0051's 90-line limit. The per-member work moves to
ExtractColumn, which returns null for a member that is not a mapped column.
Expression-bodied methods, constructors, operators and local functions and
if without braces warn in the IDE; properties and their accessors are exempt
(IDE0027 off). Private consts are PascalCase. MA0051 is enabled at 90 lines /
50 statements instead of suppressed. .gitattributes pins eol=lf so a blob's
line endings never depend on who committed it.
STYLEGUIDE.md and ARCHITECTURE.md follow ExcelReader's, adapted to this
library's twins (runtime and generated, stream/async/pipe readers) and its
untrusted-input rules, and add the rule to avoid else. docs/ holds the
measurements behind design decisions, starting with the writer. CONTRIBUTING
and SECURITY are rewritten in the same shape, and a code of conduct is added.
FixedWidthReader<T>.AggregateFileAsync and FixedWidth.AggregateFileAsync cut a
file into byte ranges, fold each on a worker thread into an
IRecordAccumulator<TSelf, TModel>, and merge the accumulators in file order.

A line belongs to the range its first byte falls in. Fixed-width lines have no
quoting, so a worker that starts one byte before its range and skips the line
it lands inside finds the exact boundary: every line is parsed exactly once.
Line numbers in errors match a sequential read; they are counted only when a
line fails. One range is the sequential read, so DegreeOfParallelism = 1 and
small files run the same code.
FixedWidthReader<T>.AggregateAsync(Stream) and FixedWidth.AggregateAsync fold a
stream across several threads. A stream cannot be read at an offset, so one
producer reads it into pooled 256 KB chunks cut after the last line break and
hands them through a bounded queue to the workers; the accumulators are merged
in chunk order.

The producer counts the line breaks it hands over, so every chunk knows the
number of its first line and errors carry the sequential line number. A
producer failure lies past every chunk it emitted, so a worker's failure is
thrown first. DegreeOfParallelism = 1 reads with the async enumerator.

LineBufferState.LineNumber becomes a long: a chunk's first line number can pass
int.MaxValue, and so could the sequential reader's count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The dedicated PipeRecordEnumerator read a 104 MB file in 472 ms; the same
pipe through PipeReader.AsStream() into the Stream reader took 187-230 ms. It
also duplicated every line rule LineBufferState implements, so removing it
leaves one line splitter for every reader. Measurements are in
docs/pipe-reader.md.

BREAKING CHANGE: ReadAsync(PipeReader) is removed from FixedWidthReader<T> and
FixedWidth, with FixedWidthPipeRecordEnumerable and PipeRecordEnumerator. Pass
pipeReader.AsStream() to the Stream overloads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FixedWidthLayout.FromAttributes<T>() builds a model's column parsers and
formatters once and caches them; FixedWidth.TryParse and TryFormat take a
layout. The runtime parser and writer now sit on top of it. A model that only
one side can handle keeps that side's error until the side is first used.
FixedWidth.Read, ReadFile, ReadAsync, ReadFileAsync, AggregateFileAsync and
AggregateAsync gain a layout overload next to each generated one. A
LayoutLineParser<T> strategy replaces ReflectionLineParser<T>, so the
enumerators and both parallel aggregators are unchanged.
FixedWidth.Write, WriteMany, WriteAsync and WriteManyAsync each come as a
generated overload and a layout overload. Batching moves out of
FixedWidthWriter<T> into RecordWriter, generic over an IRecordFormatter<T>
struct like the read side. A generated model has no known line length, so a
record that does not fit flushes the batch and, if still too long, grows it.
Generated models can now write to a stream.
FixedWidthLayoutBuilder<T> binds each column with a ColumnGetter and a
ColumnSetter: Column (string or UTF-8 parsable), DecodedColumn,
NullableColumn, ConvertedColumn and Factory. Per-column settings come in
FixedWidthColumnOptions, with FixedColumnAttribute's defaults. The builder
reaches the same typed parse and format builders as the attribute path, by
generics instead of reflection, so the two produce identical results and Build
stays trimming- and AOT-safe.
FixedWidthParser<T>, FixedWidthReader<T> and FixedWidthWriter<T> are removed.
Attribute-mapped models are read and written through
FixedWidth.*(..., FixedWidthLayout.FromAttributes<T>(), ...), the same
overloads fluent layouts use. Tests and benchmarks are migrated; the package
validation suppressions now list the removed types.

BREAKING CHANGE: replace new FixedWidthReader<T>(options).ReadFile(path) with
FixedWidth.ReadFile(path, FixedWidthLayout.FromAttributes<T>(), options), and
likewise for the parser and the writer.
…write timings

tests/AotSmoke builds a layout with FixedWidthLayout.Build and round-trips
1,000 records through FixedWidth; publishing it with NativeAOT must report no
trimming or AOT warning. docs/fluent-layout.md compares reading and writing
through layouts with the removed classes: every figure is within noise.
README shows reading and writing through FixedWidth with a layout and adds a
section on fluent layouts. ARCHITECTURE describes the layout object, its two
sources and RecordWriter. STYLEGUIDE and CHANGELOG follow the removal of the
runtime classes.
Under the parallel test run, the per-line allocation guard sometimes saw a
one-off ~8 KB allocation on its thread during one read (2.02 B/line over
4,000 extra lines); the baseline commit shows the same kind of failure. The
guard now keeps the smallest of three measurements: a real per-line
allocation still shows in all three, as a deliberately injected 24 B/line
one does.
NullableDecodedColumn<T> covers DateOnly?, DateTime? and other types that
parse only from characters; NullableConvertedColumn<T> covers T? through an
IFixedWidthConverter<T>. Both were possible with attributes and not with the
fluent builder. Also adds sync/async parity tests for records longer than a
write batch, for generated and layout models.
…to Infrastructure

Alignment, TrimMode, OverflowBehavior, FixedColumnAttribute and
IFixedWidthConverter<T> now live in FixedWidthParser, so a fluent layout or a
converter needs no extra using. The types that are public only for generated
code or allocation-free enumeration (FixedWidthRuntime, ColumnFormatOptions,
the record enumerables and enumerators, IRecordLineParser and the line-parser
structs) move to FixedWidthParser.Infrastructure. The generator emits the new
names.

BREAKING CHANGE: replace using FixedWidthParser.Attributes and
using FixedWidthParser.Processors with using FixedWidthParser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Aggregate methods take cancellationToken, like the write methods, instead of
ct. IFixedWidthConverter<T> takes column and bytesWritten, like the rest of the
library, instead of field and written. Named arguments make parameter names
part of the contract, so they are settled before 2.0 ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DegreeOfParallelism is now a property of FixedWidthReaderOptions, ignored by
reads that are not aggregations. The aggregate methods lose their parallel
parameter, and a later parallelism setting is one more property instead of a
new type or parameter.

BREAKING CHANGE: pass new FixedWidthReaderOptions { DegreeOfParallelism = n }
instead of new FixedWidthParallelOptions { DegreeOfParallelism = n }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ructs

ColumnFormatOptions is a plain readonly struct with an explicit constructor
instead of a positional record struct, and WriteInto is internal. The new
ColumnParseOptions (trim character, trim mode, format) replaces the optional
trimChar/trimMode parameters and the format argument of every
FixedWidthRuntime parse helper. Generated code stores one of each per column
and passes them by in reference, so a later setting is a new constructor
overload and property, not a change to helpers that compiled consumer code
already binds to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every type in FixedWidthParser.Infrastructure and FixedWidthContext's
ReportFailedColumn/ReportShortLine are public only for generated code and
allocation-free enumeration; [EditorBrowsable(Never)] keeps them out of
completion lists so callers do not build on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ules

README's versioning section no longer claims the enumerator and strategy types
can change in a minor version; it states that everything public, the
Infrastructure namespace and generated-code helpers included, stays binary
compatible across 2.x, and how settings are added. Samples use the root
namespace and FixedWidthReaderOptions.DegreeOfParallelism. ARCHITECTURE gains
a public-surface section; CHANGELOG covers the namespace move and renames.
Package validation suppressions follow the moved types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read, ReadFile, ReadAsync, ReadFileAsync, AggregateFileAsync and
AggregateAsync, and IRecordAccumulator, now take `allows ref struct`
models, generated and through a layout.

An enumerator cannot hold a ref struct field, so, following ExcelReader,
MoveNext only finds the next line and Current parses it on access. A
foreach still parses each line once. With OnParseError set, MoveNext also
parses the line so a bad one can be skipped before it is yielded.
ParallelAggregator now reads through IRandomAccessSource (a file handle
today, native memory next) from a data start offset, and can hand lines
to an internal ILineSink instead of a model accumulator. The native
library uses both; the public API does not change.

The model path keeps its own loop: routing it through a generic sink
made one-call reads ~40% slower under dynamic PGO.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FixedWidthParser.Native publishes as a C-ABI shared library with the
same settings as ExcelReader.Native: invariant globalization, an AVX2
floor on x64, a handle table and a per-thread last error. It exports
fw_abi_version, fw_last_error and fw_last_parse_error so far.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fw_layout_create validates fw_column_spec arrays into an immutable layout
handle shared by every read and write: positions through the core's
ColumnLayoutValidator, types, scale, formats, ASCII trim and padding,
overflow, and the struct_size contract. fw_layout_free releases it.
fw_read_file and fw_read_memory parse every line straight into native
column buffers (int64, float64 with implied decimals, decimal, date,
time, timestamp, UTF-8 strings with int32 offsets, validity bitmaps on
the first null) through the core's partitioned reader, and publish them
as an fw_table freed with fw_free_table. fw_read_options adds
header_lines, skip_invalid, encoding, max_line_length and the byte order
mark switch; fw_read_result reports lines read and skipped; a parse
error sets the line and the layout column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GabrielMarquezMatte added 24 commits October 24, 2026 03:07
Non-decimal input to a Decimal column went through float64: large integers lost digits and out-of-range values wrapped to int64 min. Integers now scale exactly in int64; NaN, infinity and values outside int64 once scaled raise FixedWidthError before anything is written. Floats keep rounding half to even to the scale, now documented.
…ing work

Workers kept every chunk's accumulator until EOF, so memory grew with the source despite the bounded queue. Results now merge left to right as soon as every earlier chunk has merged, and each chunk holds one of four slots per worker until then, so a slow early chunk stalls the producer instead of letting later results pile up. An OperationCanceledException that is not the caller's cancellation now fails its chunk instead of leaving the producer waiting on its slot.
The generator read only members declared on the model, so a derived model silently skipped base-class columns when parsing and wrote filler over them. It now walks the base types as reflection does: the first public member of a name wins, so overrides and 'new' members replace what they hide, and an override inherits [FixedColumn] from the property it overrides. Inherited columns take part in overlap validation.
Decode rented a char array for wide columns before Encoding.GetChars, and its callers' try/finally began only after it returned, so invalid bytes under a strict encoding lost the array to the pool.
lib.exe was always given /machine:x64, so ARM64 builds got an x64 import library. The machine now comes from the target compiler's architecture ID, which holds when cross-compiling. The MSVC tests also define _CRT_SECURE_NO_WARNINGS: /WX turned the std::fopen deprecation warning into an error.
csFastFloat ran only when the decimal separator was '.'; every other culture decoded the column and used the BCL parser, ~40 ns more per value. It now runs with any one-ASCII-character separator when the negative sign is '-', reusing FixedWidthContext.DecimalSeparator from the writer instead of a separate cache, and still falls back unless every byte was consumed. A pt-BR read goes from 98.4 to 58.5 ns/line.
A StringPool lookup cost several times a fresh GetString, so a pooled read ran at 137.8 ns/line against 57.1 without. A per-thread, 4-way set-associative cache, as in ExcelReader's Utf8StringCache, now answers repeated UTF-8 values; every hit is checked against the bytes and a miss still goes to the pool. Pooled reads run at ~70 ns/line. docs/reader-performance.md records both changes and why larger reads in the file engine were not kept.
The README was the NuGet landing page, the whole usage manual and a benchmark report at once. Following ExcelReader, it keeps the overview, installation and a quick start, and links to docs/guide/ (models and layouts, reading, writing, versioning), whose text moved unchanged. The comparison tables were 1.1 numbers from another machine; docs/performance/benchmarks.md has 2.0 numbers, the machine, and why the comparison is not like-for-like.
The changelog said the double fast path ran only for '.', and missed the comma-culture and StringPool speedups, C++ struct mapping and the generator's inherited-columns fix. ARCHITECTURE listed retired diagnostics, STYLEGUIDE a runtime constructor 2.0 no longer has and three of four twin paths, and CONTRIBUTING two of three warnings-as-errors projects and only part of CI, with no steps for the native, Python, C++ or AOT checks. The native README now names fixedwidth_arrow.h, SECURITY.md covers the native library and bindings, and the PR template asks for the changelog and PublicAPI entries. Inherited columns, Python's LayoutOptions, WriteOptions and Table members, and C++'s layout_for, at, table and TypedReader are documented; parallel-aggregate.md says its stream rows predate incremental merging.
2.0 breaks the 1.x API on purpose, so validating against the 1.1.1
baseline only produced a 372-line suppression list regenerated on every
break. Validation goes back on with a 2.0.0 baseline once 2.0.0 ships;
CONTRIBUTING and the CI step name say so.
…FastDecimal

double, float and decimal columns now parse through built-in fast paths
that take the culture's decimal separator as a parameter, so comma
cultures stay fast. Anything a fast path refuses (thousands separators,
trailing text, too many digits) goes to the BCL over the decoded text,
which decides the result; the BCL's UTF-8 parser would reject a space
standing in for fr-FR's no-break-space group separator.

float gets fast_float's binary32 fast path (a mantissa up to 2^24 and a
scale up to 10, one rounding) rather than a rounded double, which can be
one ulp off. decimal keeps the scale as written.

CultureHelpers folds into FixedWidthRuntime, where one decode helper now
serves the number fallback, the exact date/time parsers and
TryParseDecoded. New tests check float and decimal against the BCL in
invariant, de-DE and fr-FR. A 2M-line read of string, int, double,
decimal and float columns drops from ~163 to ~122 ns/line.
- ColumnParserFactory: nine near-identical parse-then-assign builders
  become one Bind over a ColumnValueParser, fed the FixedWidthRuntime
  helpers as method groups; ForString and ForConverter stop forwarding.
- ColumnFormatOptions.WriteInto reuses AlignInPlace instead of repeating
  the alignment and sign-leftmost logic.
- LineBufferState.Fill(Stream) replaces the refill loop copied in the
  sync enumerator and the native table reader.
- ResolveOverflow becomes one switch expression; the unused
  ParserBuilder.ComputeRequiredLineLength is gone.
- FixedColumnAttribute docs no longer mention the removed
  IUtf8FixedWidthConverter<T>.
EpochDay and Pow10 were declared in both ColumnBuilder and
TableFormatter; they move to NativeType. NativeLayout and WriteSettings
each had the same Fail helper; NativeApi.InvalidArgument replaces both.
GrowableNativeBuffer loses Last, CopyTo and ByteLength, which nothing
called.
The hand-written Equals and GetHashCode over fourteen fields become the
compiler's, with an IsExternalInit polyfill for netstandard2.0. ModelInfo
keeps its own equality, since it leaves Location out. Drops a stale
comment on ByteLiteral.
A date or time format made only of fixed-width digit fields (yyyy, MM,
dd, HH, mm, ss) and the literals - . T / : is compiled once per column
into a FastDate that reads the bytes directly, as ExcelReader's FastDate
reads ISO 8601. It steps aside for a non-Gregorian calendar, and for "/"
or ":" when the culture's separators differ. DateTime, DateOnly,
TimeOnly and native TIMESTAMP columns use it: 85 -> 18 ns per value.

int and long columns of plain digits, and the native INT64, implied-
decimal FLOAT64 and DECIMAL columns, go through FastInteger, which
reuses FastDouble's eight-digit step: 14 -> 9 ns per value. A leading
"-" is taken only when the culture's negative sign is "-".

Anything either refuses goes to the BCL. New tests check both against
it in cultures where the fast paths must not apply (th-TH, de-DE,
sv-SE). A single-threaded COTAHIST read through Python drops from 0.920
to 0.678 s; the parallel read barely moves (0.261 -> 0.250 s).
Timed inside the native read, every partition stayed busy but slowed as
threads were added (1.0x at 2, 1.7x at 8, 3x at 16), with an in-memory
source no better than a file. A read writes ~200 MB of columns: 50,000
demand-zero page faults on one thread, 100,000 in parallel because the
concatenation copies everything into fresh memory again.

- A partition sizes its column buffers once from its byte count, which
  fixed-width lines turn into a row count, instead of doubling them
  from 256 elements.
- skip_invalid skips a bad line through an internal OnSkip hook instead
  of building its exception: numbering B3's trailer counted line breaks
  from the start of the file on one thread (~27 ms).
- Publish concatenates one column per thread.

COTAHIST through Python, 1M lines: fw.read 0.250 -> 0.169 s, one thread
0.678 -> 0.580 s.
bench_write.py reads a synthetic COTAHIST file once into pandas, polars
and pyarrow tables, then times each writer turning the table back into
its record lines, asserting every one reproduces the file's body byte
for byte. pandas and polars have no fixed-width writer, so the baselines
are numpy.savetxt with printf fields and polars pad + concat_str.

The generator now caps a day's quantity at 10^9 shares: its 16-digit
volumes did not survive a float64, so no writer could reproduce them.

1M lines: fw.write (pyarrow) 0.259 s, polars 0.722 s, savetxt 4.285 s.
…n parallel

At 16 threads fw_write_file took 240 ms where fw_write_memory took 141,
with the two equal on one thread: Windows runs one synchronous I/O at a
time on a handle, so the parallel batches queued. The file is now opened
for asynchronous I/O; each batch still waits for its write, but they run
together (240 -> 170 ms).

A right-aligned int or long with no format writes its digits from the
right straight into the column, then the padding, with "-" kept leftmost
(19 -> 9 ns): formatting at the start and aligning after cost a copy.
It applies only when the provider's negative sign is "-".

A date or time whose format is only digit fields reuses the reader's
compiled FastDate to write its digits (47 -> 23 ns).

A test checks both against the BCL's char path in four cultures, both
alignments, and zero and space padding. COTAHIST through Python, 1M
lines: fw.write 0.259 -> 0.162 s, one thread 0.838 -> 0.550 s.
A whole-source read allocates the final columns once and lends each
partition the slice its byte offset gives, with the first line's length
as the stride. Numeric, date and time values and STRING bytes are parsed
in place; publishing moves each partition down to close the gaps skipped
lines and trimmed text leave, and hands the buffers over. A partition
that outgrows its slice copies its column out, and that column is
concatenated as before. STRING offsets are still copied.

A parallel COTAHIST read through Python: fw.read 0.184 -> 0.153 s,
fw.read_polars 0.198 -> 0.165 s.
Follows ExcelReader's release workflow. A v* tag now builds the native
library on Windows x64, macOS arm64 and x64, and Linux glibc and musl on
x64 and arm64; tests and builds one wheel per platform with the tag's
version; publishes the NuGet package, the wheels to PyPI through Trusted
Publishing, and the native libraries and an SBOM to the GitHub Release.
A tag with a "-" is a prerelease.

check_wheel_tag.py and build_musl_wheel.sh come from ExcelReader. pyarrow
has no musllinux wheels, so the pandas, polars, Arrow and write tests
skip without it and the musl job tests the NumPy read.
@codecov

codecov Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.99834% with 145 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (develop@e87af5c). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...dWidthParser.Generator/FixedWidthModelGenerator.cs 90.23% 12 Missing and 13 partials ⚠️
src/FixedWidthParser/Infrastructure/FastDouble.cs 90.24% 11 Missing and 9 partials ⚠️
...xedWidthParser/Infrastructure/FixedWidthRuntime.cs 90.95% 11 Missing and 7 partials ⚠️
python/src/fixedwidth/table.py 88.50% 10 Missing ⚠️
python/src/fixedwidth/_native.py 91.17% 9 Missing ⚠️
...xedWidthParser/Readers/ParallelStreamAggregator.cs 92.68% 8 Missing and 1 partial ⚠️
python/src/fixedwidth/writer.py 93.75% 8 Missing ⚠️
src/FixedWidthParser/Readers/ParallelAggregator.cs 93.84% 4 Missing and 4 partials ⚠️
...FixedWidthParser/Infrastructure/Utf8StringCache.cs 86.11% 2 Missing and 3 partials ⚠️
python/src/fixedwidth/reader.py 96.22% 4 Missing ⚠️
... and 14 more
Additional details and impacted files
@@            Coverage Diff             @@
##             develop      #79   +/-   ##
==========================================
  Coverage           ?   94.35%           
==========================================
  Files              ?       44           
  Lines              ?     2779           
  Branches           ?      464           
==========================================
  Hits               ?     2622           
  Misses             ?      100           
  Partials           ?       57           
Flag Coverage Δ
csharp 94.32% <93.83%> (?)
python 94.41% <94.41%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown

Benchmark Results

Measured on ubuntu-latest (GitHub Actions). Runner noise may affect absolute numbers; use these for relative comparisons within a PR.

Benchmarks.Perf.AsyncReaderBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3

IterationCount=5  WarmupCount=1  

Method Count Mean Error StdDev Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio
GeneratedReader_ReadAsync 100 4.742 μs 0.1712 μs 0.0445 μs 0.36 0.01 0.2518 - 4264 B 0.16
GeneratedReader_ReadAsync_Pooled 100 6.239 μs 0.0318 μs 0.0049 μs 0.48 0.01 0.0153 - 344 B 0.01
Reader_ReadAsync 100 7.543 μs 0.2191 μs 0.0569 μs 0.57 0.01 0.2518 - 4280 B 0.16
Reader_ReadAsync_Pooled 100 8.971 μs 0.0642 μs 0.0099 μs 0.68 0.01 0.0153 - 360 B 0.01
Naive_ReadLineAsync 100 13.128 μs 1.1923 μs 0.3096 μs 1.00 0.03 1.6327 0.0153 27336 B 1.00
GeneratedReader_ReadAsync 1000 45.772 μs 0.5141 μs 0.1335 μs 0.35 0.00 2.3193 - 39544 B 0.163
GeneratedReader_ReadAsync_Pooled 1000 59.314 μs 0.4242 μs 0.1102 μs 0.45 0.00 - - 344 B 0.001
Reader_ReadAsync 1000 73.165 μs 0.6942 μs 0.1074 μs 0.56 0.00 2.3193 - 39560 B 0.163
Reader_ReadAsync_Pooled 1000 84.488 μs 0.2000 μs 0.0309 μs 0.64 0.00 - - 360 B 0.001
Naive_ReadLineAsync 1000 131.195 μs 1.5654 μs 0.4065 μs 1.00 0.00 14.4043 - 242616 B 1.000

Benchmarks.Perf.ComparisonBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 3.09GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3

IterationCount=5  WarmupCount=1  

Method Count Mean Error StdDev Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio
FixedWidthParser_Generated 100 5.424 μs 0.0319 μs 0.0083 μs 0.16 0.00 0.2365 - 3.89 KB 0.09
FixedWidthParser_Read 100 6.666 μs 0.0645 μs 0.0168 μs 0.20 0.00 0.2365 - 3.89 KB 0.09
RecordParser_Read 100 10.571 μs 0.1775 μs 0.0461 μs 0.32 0.00 0.2441 - 4.23 KB 0.10
FileHelpers_Read 100 33.275 μs 1.0103 μs 0.2624 μs 1.00 0.01 2.5024 0.0610 41.49 KB 1.00
FlatFiles_Read 100 70.072 μs 1.1760 μs 0.3054 μs 2.11 0.02 6.2256 0.1221 103.13 KB 2.49
FixedWidthParser_Generated 1000 60.905 μs 3.8042 μs 0.9879 μs 0.16 0.00 2.3193 - 38.34 KB 0.09
FixedWidthParser_Read 1000 68.994 μs 0.8812 μs 0.2289 μs 0.18 0.00 2.3193 - 38.34 KB 0.09
RecordParser_Read 1000 105.122 μs 1.0236 μs 0.2658 μs 0.28 0.00 2.3193 - 38.69 KB 0.10
FileHelpers_Read 1000 379.554 μs 6.0227 μs 1.5641 μs 1.00 0.01 24.4141 5.8594 406.42 KB 1.00
FlatFiles_Read 1000 690.181 μs 8.3413 μs 2.1662 μs 1.82 0.01 57.6172 1.9531 953.2 KB 2.35

Benchmarks.Perf.ComparisonWriterBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 3.09GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3

IterationCount=5  WarmupCount=1  

Method Count Mean Error StdDev Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio
FixedWidthParser_Write 1 92.48 ns 0.485 ns 0.075 ns 0.48 0.00 - - - 0.00
FileHelpers_Write 1 194.24 ns 2.052 ns 0.318 ns 1.00 0.00 0.0215 - 360 B 1.00
FlatFiles_Write 1 537.17 ns 7.588 ns 1.174 ns 2.77 0.01 0.0420 - 712 B 1.98
RecordParser_Write 1 20,588.24 ns 7,366.853 ns 1,140.029 ns 106.00 5.25 1.0986 - 18128 B 50.36
FixedWidthParser_Write 100 8,190.67 ns 21.086 ns 3.263 ns 0.43 0.00 - - - 0.00
FileHelpers_Write 100 19,039.93 ns 175.223 ns 27.116 ns 1.00 0.00 1.9531 - 32832 B 1.00
FlatFiles_Write 100 43,682.48 ns 949.043 ns 246.464 ns 2.29 0.01 4.2114 - 71200 B 2.17
RecordParser_Write 100 53,118.22 ns 8,465.386 ns 2,198.434 ns 2.79 0.11 1.2207 - 20440 B 0.62
FixedWidthParser_Write 1000 79,091.95 ns 236.689 ns 36.628 ns 0.41 0.00 - - - 0.00
FileHelpers_Write 1000 191,836.80 ns 959.702 ns 249.232 ns 1.00 0.00 19.5313 - 328032 B 1.00
FlatFiles_Write 1000 449,619.01 ns 7,687.455 ns 1,996.408 ns 2.34 0.01 42.4805 - 712000 B 2.17
RecordParser_Write 1000 459,094.18 ns 111,640.380 ns 17,276.472 ns 2.39 0.08 80.0781 78.1250 1332085 B 4.06

Benchmarks.Perf.ParserBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v4
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v4

IterationCount=5  WarmupCount=1  

Method Line Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
Parse_NoPool Jane (...)0.00 [25] 49.41 ns 0.908 ns 0.141 ns 1.00 0.0029 48 B 1.00
Parse_WithStringPool Jane (...)0.00 [25] 57.05 ns 0.428 ns 0.111 ns 1.15 - - 0.00
Parse_NoPool John (...)0.00 [25] 48.82 ns 0.325 ns 0.050 ns 1.00 0.0024 40 B 1.00
Parse_WithStringPool John (...)0.00 [25] 53.84 ns 0.147 ns 0.038 ns 1.10 - - 0.00

Benchmarks.Perf.ReaderBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3

IterationCount=5  WarmupCount=1  

Method Count Mean Error StdDev Ratio RatioSD Gen0 Gen1 Allocated Alloc Ratio
GeneratedReader_Read 100 5.494 μs 0.0806 μs 0.0125 μs 0.51 0.01 0.2365 - 3984 B 0.199
Reader_Read 100 6.950 μs 0.1177 μs 0.0182 μs 0.65 0.01 0.2365 - 3984 B 0.199
GeneratedReader_Read_Pooled 100 7.113 μs 0.0220 μs 0.0034 μs 0.66 0.01 - - 64 B 0.003
Reader_Read_Pooled 100 7.783 μs 0.0392 μs 0.0061 μs 0.73 0.01 - - 64 B 0.003
Naive_ReadLine 100 10.707 μs 0.5454 μs 0.1416 μs 1.00 0.02 1.1902 - 20064 B 1.000
GeneratedReader_Read 1000 56.702 μs 0.4411 μs 0.1145 μs 0.53 0.00 2.3193 - 39264 B 0.230
GeneratedReader_Read_Pooled 1000 66.851 μs 0.2330 μs 0.0605 μs 0.62 0.00 - - 64 B 0.000
Reader_Read 1000 68.274 μs 0.8699 μs 0.2259 μs 0.63 0.00 2.3193 - 39264 B 0.230
Reader_Read_Pooled 1000 80.189 μs 0.1759 μs 0.0457 μs 0.74 0.00 - - 64 B 0.000
Naive_ReadLine 1000 107.641 μs 1.9122 μs 0.4966 μs 1.00 0.01 10.1318 0.1221 170544 B 1.000

Benchmarks.Perf.ReaderPoolingBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3

IterationCount=5  WarmupCount=1  

Method Count Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
NoString 1000 39.72 μs 0.249 μs 0.065 μs 0.55 0.02 - 64 B 0.002
WithString_NoPool 1000 72.17 μs 9.812 μs 2.548 μs 1.00 0.05 2.3193 39264 B 1.000
WithString_Pooled 1000 81.71 μs 0.795 μs 0.123 μs 1.13 0.04 - 64 B 0.002

Benchmarks.Perf.ReaderStartupBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3

IterationCount=5  WarmupCount=1  

Method Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
GeneratedReader_ReadOne 116.9 ns 3.52 ns 0.91 ns 0.88 0.0062 104 B 1.00
ReflectionReader_ConstructAndReadOne 133.2 ns 2.44 ns 0.63 ns 1.00 0.0062 104 B 1.00

Benchmarks.Perf.SourceGenParserBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v4
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v4

IterationCount=5  WarmupCount=1  

Method Line Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
Generated John (...)00.00 [25] 36.80 ns 2.202 ns 0.572 ns 0.78 0.0024 40 B 1.00
Reflection John (...)00.00 [25] 47.23 ns 0.712 ns 0.185 ns 1.00 0.0024 40 B 1.00

Benchmarks.Perf.WriterBenchmarks


BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3
  Job-MEHJPP : .NET 10.0.13 (10.0.13, 10.0.1326.50504), X64 RyuJIT x86-64-v3

IterationCount=5  WarmupCount=1  

Method Count Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
WriteMany_Span 1 97.10 ns 0.201 ns 0.031 ns 0.94 0.05 - - 0.00
WriteMany_Enumerable 1 103.15 ns 22.212 ns 5.768 ns 1.00 0.07 0.0019 32 B 1.00
WriteManyAsync 1 107.44 ns 1.301 ns 0.338 ns 1.04 0.05 0.0019 32 B 1.00
WriteManyAsync 100 6,792.25 ns 6.232 ns 0.964 ns 0.86 0.00 - 32 B 1.00
WriteMany_Span 100 7,836.63 ns 35.289 ns 9.164 ns 0.99 0.00 - - 0.00
WriteMany_Enumerable 100 7,902.70 ns 154.365 ns 40.088 ns 1.00 0.01 - 32 B 1.00
WriteManyAsync 1000 59,972.05 ns 373.821 ns 57.849 ns 0.75 0.00 - 32 B 1.00
WriteMany_Span 1000 79,782.26 ns 110.436 ns 28.680 ns 1.00 0.00 - - 0.00
WriteMany_Enumerable 1000 80,026.16 ns 541.131 ns 140.530 ns 1.00 0.00 - 32 B 1.00

Follows ExcelReader's workflows.

- benchmark-pr is opt-in through the run-benchmarks label, reads its groups
  from tests/Benchmarks/benchmark-groups.json (shared with benchmark-pages),
  guards that every benchmark belongs to a group, and comments a delta
  against the published master series. benchmark-pages runs only when its
  paths change. The stale IntHeavyParserBenchmarks pattern is gone.
- ci filters by path and skips drafts. The required Build & Test jobs
  always start and skip their steps. Linux builds the solution with the
  analyzers, checks whitespace, packs and runs every test with coverage;
  Windows and macOS build and run the library tests without analyzers.
  The dependency audit moves to audit.yml, weekly and on dependency changes.
- codeql filters by path the same way, adds Python, and builds only the
  shipped projects without analyzers.
- native-bindings splits into Python, C++ and musl jobs with their own
  filters, tests every platform a release ships, and builds the wheel.
- codecov.yml flags C# and Python coverage with carryforward;
  coverage.runsettings leaves out generated code and the C ABI exports.
- release promotes PublicAPI.Unshipped to Shipped in a PR after a stable tag.
- global.json pins SDK 10.0.402, which every workflow now installs and caches.
- Dependabot groups follow the test and benchmark packages in use.
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@GabrielMarquezMatte
GabrielMarquezMatte merged commit 2a387c4 into develop Oct 25, 2026
35 checks 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.

3 participants