From 3040c1f84f8a208d0631b92fe936acc7cebf8585 Mon Sep 17 00:00:00 2001 From: Chris Richards Date: Mon, 6 Jul 2026 16:37:10 +0100 Subject: [PATCH 1/3] Add index benchmarks against a generated 50k-row indexed table The repository fixtures are a handful of rows, so index benchmarks need generated data: BenchmarkTableGenerator writes a 50,000-row DBF plus a matching compound index (two tags: unique integer IDs and duplicated character codes) using the simplest structure the library's strict CDX reader accepts - uncompressed leaf entries, max-key interior entries, and a two-tag directory. Generated files are verified for index/scan result equivalence through the public API before anything is measured. IndexQueryBenchmarks pairs the same SQL with UseIndexes on and off: equality seek 459x faster (50us vs 23ms), range scan 233x, character seek 141x, top-10 descending order 20x, filtered COUNT(*) 24x, and the whole-table COUNT(*) status scan cuts allocations from 16.8MB to 10KB. Results recorded in benchmarks.md. The benchmarks project now references the published DbfDataReader 2.1.0 package. Co-Authored-By: Claude Fable 5 --- benchmarks.md | 33 ++ .../BenchmarkTableGenerator.cs | 420 ++++++++++++++++++ .../DbfDataReader.Benchmarks.csproj | 2 +- .../IndexQueryBenchmarks.cs | 132 ++++++ 4 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 test/DbfDataReader.Benchmarks/BenchmarkTableGenerator.cs create mode 100644 test/DbfDataReader.Benchmarks/IndexQueryBenchmarks.cs diff --git a/benchmarks.md b/benchmarks.md index c9e9074..15d8eb2 100644 --- a/benchmarks.md +++ b/benchmarks.md @@ -25,3 +25,36 @@ Outliers DbfDataReaderBenchmarks.Sylvan: IterationTime=1.0000 s -> 3 outliers were removed (2.12 ms..2.18 ms) DbfDataReaderBenchmarks.NDbf: IterationTime=1.0000 s -> 2 outliers were removed (4.37 ms, 4.44 ms) + +# v2.1.0 — automatic index use + +Generated 50,000-row table (65-byte records) with a compound index on `ID` (unique +integers) and `CODE` (character keys, ~100 rows per value); see +`BenchmarkTableGenerator` in `test/DbfDataReader.Benchmarks`. Each pair runs the same +SQL through `DbfDbConnection` with `UseIndexes` on and off; the generated files are +verified for index/scan equivalence before anything is measured. + +Reproduce with: +`dotnet run -c Release --project test/DbfDataReader.Benchmarks -- --filter "*IndexQueryBenchmarks*"` + +BenchmarkDotNet v0.15.8, macOS (Apple M3 Max), .NET 10.0.2, Job=ShortRun + +| Method | Categories | Mean | Ratio | Allocated | Alloc Ratio | +|---------------- |----------------------------- |-------------:|------:|------------:|------------:| +| 'full scan' | character seek (100 rows) | 22,174.38 us | 1.000 | 16813.84 KB | 1.000 | +| index | character seek (100 rows) | 156.94 us | 0.007 | 81.27 KB | 0.005 | +| | | | | | | +| 'read all rows' | count(*) all rows | 21,752.63 us | 1.00 | 16804.55 KB | 1.000 | +| 'status scan' | count(*) all rows | 15,131.75 us | 0.70 | 10.42 KB | 0.001 | +| | | | | | | +| 'full scan' | count(*) filtered (10k rows) | 23,582.86 us | 1.00 | 17980.82 KB | 1.00 | +| 'index only' | count(*) filtered (10k rows) | 972.50 us | 0.04 | 1201.05 KB | 0.07 | +| | | | | | | +| 'full scan' | equality seek (1 row) | 22,998.16 us | 1.000 | 17980.99 KB | 1.000 | +| index | equality seek (1 row) | 50.12 us | 0.002 | 31.98 KB | 0.002 | +| | | | | | | +| 'full scan' | range scan (100 rows) | 23,776.99 us | 1.000 | 17983.64 KB | 1.000 | +| index | range scan (100 rows) | 102.24 us | 0.004 | 81.12 KB | 0.005 | +| | | | | | | +| 'scan + sort' | top 10 order by desc | 60,556.31 us | 1.00 | 66357.99 KB | 1.00 | +| index | top 10 order by desc | 2,986.39 us | 0.05 | 5661.96 KB | 0.09 | diff --git a/test/DbfDataReader.Benchmarks/BenchmarkTableGenerator.cs b/test/DbfDataReader.Benchmarks/BenchmarkTableGenerator.cs new file mode 100644 index 0000000..113a180 --- /dev/null +++ b/test/DbfDataReader.Benchmarks/BenchmarkTableGenerator.cs @@ -0,0 +1,420 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace DbfDataReader.Benchmarks +{ + // Generates a large DBF table with a matching compound index so that index + // benchmarks have something realistic to run against (the repository fixtures are + // a handful of rows). The CDX writer produces the simplest structure the library's + // strict reader accepts: uncompressed leaf entries (no duplicate/trailing + // compression), max-key interior entries, and a two-tag directory. Generated files + // are verified through the public API before any benchmark runs. + public static class BenchmarkTableGenerator + { + private const int NodeSize = 512; + private const int HeaderSize = 1024; + private const int PackedAreaLength = 488; + private const int PackedEntryLength = 6; // 32-bit record number + 8-bit dup + 8-bit trail + + public static string DbfPath(string directory) => Path.Combine(directory, "bench.dbf"); + + public static void Generate(string directory, int rowCount) + { + Directory.CreateDirectory(directory); + WriteDbf(DbfPath(directory), rowCount); + WriteCdx(Path.Combine(directory, "bench.cdx"), rowCount); + } + + public static string Code(int rowIndex) => $"C{rowIndex % 500:D6}"; + + // record layout: status byte + ID ('I', 4) + CODE ('C', 10) + NAME ('C', 40) + VALUE ('N', 10) + private static void WriteDbf(string path, int rowCount) + { + var columns = new (string Name, char Type, byte Length)[] + { + ("ID", 'I', 4), + ("CODE", 'C', 10), + ("NAME", 'C', 40), + ("VALUE", 'N', 10) + }; + + var headerLength = (ushort)(32 + columns.Length * 32 + 1); + var recordLength = (ushort)(1 + columns.Sum(c => c.Length)); + + using var stream = File.Create(path); + using var writer = new BinaryWriter(stream, Encoding.ASCII); + + var header = new byte[32]; + header[0] = 0x03; // dBase III, no memo + header[1] = 26; + header[2] = 7; + header[3] = 6; + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(4), (uint)rowCount); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(8), headerLength); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(10), recordLength); + header[29] = 0x03; // cp1252 + writer.Write(header); + + foreach (var (name, type, length) in columns) + { + var descriptor = new byte[32]; + Encoding.ASCII.GetBytes(name).CopyTo(descriptor, 0); + descriptor[11] = (byte)type; + descriptor[16] = length; + writer.Write(descriptor); + } + + writer.Write((byte)0x0D); + + var record = new byte[recordLength]; + for (var i = 0; i < rowCount; i++) + { + record[0] = 0x20; + BinaryPrimitives.WriteInt32LittleEndian(record.AsSpan(1), i + 1); + WriteText(record, 5, 10, Code(i)); + WriteText(record, 15, 40, $"ROW {i} FILLER TEXT"); + WriteText(record, 55, 10, ((i * 7) % 100000).ToString().PadLeft(10)); + writer.Write(record); + } + + writer.Write((byte)0x1A); + } + + private static void WriteText(byte[] record, int offset, int length, string text) + { + for (var i = 0; i < length; i++) + { + record[offset + i] = (byte)(i < text.Length ? text[i] : ' '); + } + } + + private static void WriteCdx(string path, int rowCount) + { + // ID: unique ascending integer keys; CODE: duplicated character keys + var idEntries = Enumerable.Range(0, rowCount) + .Select(i => (Key: EncodeIntegerKey(i + 1), RecNo: i + 1)) + .ToList(); + var codeEntries = Enumerable.Range(0, rowCount) + .Select(i => (Key: PadKey(Code(i), 10), RecNo: i + 1)) + .OrderBy(e => e.Key, ByteArrayComparer.Instance) + .ThenBy(e => e.RecNo) + .ToList(); + + var idNodes = BuildTree(idEntries, 4); + var codeNodes = BuildTree(codeEntries, 10); + + var idHeaderOffset = HeaderSize + NodeSize; // after file header + tag directory + var idNodesOffset = idHeaderOffset + HeaderSize; + var codeHeaderOffset = idNodesOffset + idNodes.Count * NodeSize; + var codeNodesOffset = codeHeaderOffset + HeaderSize; + + using var stream = File.Create(path); + + // file header: the root points at the tag directory; tag-name keys are 10 bytes + WriteHeader(stream, 0, HeaderSize, keyLength: 10, keyExpression: ""); + + // tag directory: one leaf, entries sorted by tag name, record numbers are + // the file offsets of the tag headers + var directory = new TreeNode(isLeaf: true) + { + Entries = + { + (PadKey("CODE", 10), codeHeaderOffset, -1), + (PadKey("ID", 10), idHeaderOffset, -1) + } + }; + WriteNode(stream, HeaderSize, directory, keyLength: 10, isRoot: true, offsetOf: null, + trailFor: key => 10 - TrimmedLength(key)); + + WriteHeader(stream, idHeaderOffset, idNodesOffset + idNodes.Count * NodeSize, keyLength: 4, + keyExpression: "ID", rootNodeOffset: idNodesOffset + (idNodes.Count - 1) * NodeSize); + WriteTree(stream, idNodes, idNodesOffset, 4); + + WriteHeader(stream, codeHeaderOffset, codeNodesOffset + codeNodes.Count * NodeSize, keyLength: 10, + keyExpression: "CODE", rootNodeOffset: codeNodesOffset + (codeNodes.Count - 1) * NodeSize); + WriteTree(stream, codeNodes, codeNodesOffset, 10); + } + + private sealed class TreeNode + { + public TreeNode(bool isLeaf) + { + IsLeaf = isLeaf; + } + + public bool IsLeaf { get; } + public List<(byte[] Key, int RecNo, int ChildIndex)> Entries { get; } = new(); + public int LeftIndex = -1; + public int RightIndex = -1; + } + + // builds leaves left to right, then interior levels bottom-up; the returned + // list is in file order and the last node is the root + private static List BuildTree(List<(byte[] Key, int RecNo)> entries, int keyLength) + { + var nodes = new List(); + var leafCapacity = PackedAreaLength / (PackedEntryLength + keyLength); + var interiorCapacity = 500 / (keyLength + 8); + + // leaf level + var level = new List(); + for (var start = 0; start < entries.Count; start += leafCapacity) + { + var node = new TreeNode(isLeaf: true); + foreach (var (key, recNo) in entries.Skip(start).Take(leafCapacity)) + { + node.Entries.Add((key, recNo, -1)); + } + + nodes.Add(node); + level.Add(nodes.Count - 1); + } + + LinkSiblings(nodes, level); + + // interior levels until a single root remains + while (level.Count > 1) + { + var parents = new List(); + for (var start = 0; start < level.Count; start += interiorCapacity) + { + var node = new TreeNode(isLeaf: false); + foreach (var childIndex in level.Skip(start).Take(interiorCapacity)) + { + var child = nodes[childIndex]; + var (maxKey, maxRecNo, _) = child.Entries[^1]; + node.Entries.Add((maxKey, maxRecNo, childIndex)); + } + + nodes.Add(node); + parents.Add(nodes.Count - 1); + } + + LinkSiblings(nodes, parents); + level = parents; + } + + // move the root to the end so its offset is the last node slot + var rootIndex = level[0]; + if (rootIndex != nodes.Count - 1) + { + var root = nodes[rootIndex]; + nodes.RemoveAt(rootIndex); + nodes.Add(root); + } + + return nodes; + } + + private static void LinkSiblings(List nodes, List level) + { + for (var i = 0; i < level.Count; i++) + { + nodes[level[i]].LeftIndex = i > 0 ? level[i - 1] : -1; + nodes[level[i]].RightIndex = i < level.Count - 1 ? level[i + 1] : -1; + } + } + + private static void WriteTree(Stream stream, List nodes, int baseOffset, int keyLength) + { + int OffsetOf(int index) => baseOffset + index * NodeSize; + + for (var i = 0; i < nodes.Count; i++) + { + WriteNode(stream, OffsetOf(i), nodes[i], keyLength, isRoot: i == nodes.Count - 1, OffsetOf, + trailFor: _ => 0); + } + } + + private static void WriteNode(Stream stream, long offset, TreeNode node, int keyLength, bool isRoot, + Func offsetOf, Func trailFor) + { + var buffer = new byte[NodeSize]; + var attributes = (ushort)((node.IsLeaf ? 2 : 0) | (isRoot ? 1 : 0)); + + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0), attributes); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2), (ushort)node.Entries.Count); + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(4), + node.LeftIndex < 0 ? -1 : offsetOf(node.LeftIndex)); + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(8), + node.RightIndex < 0 ? -1 : offsetOf(node.RightIndex)); + + if (node.IsLeaf) + { + WriteLeafEntries(buffer, node, keyLength, trailFor); + } + else + { + WriteInteriorEntries(buffer, node, keyLength, offsetOf); + } + + stream.Seek(offset, SeekOrigin.Begin); + stream.Write(buffer, 0, buffer.Length); + } + + private static void WriteLeafEntries(byte[] buffer, TreeNode node, int keyLength, Func trailFor) + { + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(14), 0xFFFFFFFF); // record number mask + buffer[18] = 0xFF; // duplicate count mask + buffer[19] = 0xFF; // trailing count mask + buffer[20] = 32; // record number bits + buffer[21] = 8; // duplicate count bits + buffer[22] = 8; // trailing count bits + buffer[23] = PackedEntryLength; + + var packed = buffer.AsSpan(24, PackedAreaLength); + var keyStart = PackedAreaLength; + + for (var i = 0; i < node.Entries.Count; i++) + { + var (key, recNo, _) = node.Entries[i]; + var trail = trailFor(key); + var newBytes = keyLength - trail; + + var packedEntry = (ulong)(uint)recNo | ((ulong)trail << 40); + for (var b = 0; b < PackedEntryLength; b++) + { + packed[i * PackedEntryLength + b] = (byte)(packedEntry >> (8 * b)); + } + + keyStart -= newBytes; + key.AsSpan(0, newBytes).CopyTo(packed.Slice(keyStart, newBytes)); + } + } + + private static void WriteInteriorEntries(byte[] buffer, TreeNode node, int keyLength, + Func offsetOf) + { + var entrySize = keyLength + 8; + for (var i = 0; i < node.Entries.Count; i++) + { + var (key, recNo, childIndex) = node.Entries[i]; + var entry = buffer.AsSpan(12 + i * entrySize, entrySize); + + key.CopyTo(entry); + BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(keyLength), (uint)recNo); + BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(keyLength + 4), (uint)offsetOf(childIndex)); + } + } + + private static void WriteHeader(Stream stream, long offset, long endOffset, int keyLength, + string keyExpression, long rootNodeOffset = -1) + { + var buffer = new byte[HeaderSize]; + + var root = rootNodeOffset >= 0 ? rootNodeOffset : offset + HeaderSize; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0), (uint)root); + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(4), -1); // free node list + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(12), (ushort)keyLength); + buffer[14] = 0x60; // compact + compound + buffer[15] = 1; // signature + // 502: ascending order (zero); 506: FOR expression length (zero) + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(510), (ushort)(keyExpression.Length + 1)); + Encoding.ASCII.GetBytes(keyExpression).CopyTo(buffer, 512); + + stream.Seek(offset, SeekOrigin.Begin); + stream.Write(buffer, 0, buffer.Length); + + if (stream.Length < endOffset) stream.SetLength(endOffset); + } + + private static byte[] EncodeIntegerKey(int value) + { + var key = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(key, value); + key[0] ^= 0x80; + return key; + } + + private static byte[] PadKey(string text, int length) + { + var key = new byte[length]; + for (var i = 0; i < length; i++) + { + key[i] = (byte)(i < text.Length ? text[i] : ' '); + } + + return key; + } + + private static int TrimmedLength(byte[] key) + { + var length = key.Length; + while (length > 0 && key[length - 1] == 0x20) length--; + return length; + } + + private sealed class ByteArrayComparer : IComparer + { + public static readonly ByteArrayComparer Instance = new(); + + public int Compare(byte[] x, byte[] y) + { + for (var i = 0; i < Math.Min(x!.Length, y!.Length); i++) + { + var cmp = x[i].CompareTo(y[i]); + if (cmp != 0) return cmp; + } + + return x.Length.CompareTo(y.Length); + } + } + + // sanity checks through the public API; throws when the generated files do not + // behave identically with and without indexes + public static void Verify(string directory, int rowCount) + { + using (var table = new DbfTable(DbfPath(directory))) + { + if (table.Header.RecordCount != rowCount) + throw new InvalidOperationException("record count mismatch"); + } + + var probes = new[] + { + $"select * from bench.dbf where ID = {rowCount / 2}", + "select * from bench.dbf where CODE = 'C000123'", + $"select ID from bench.dbf where ID between {rowCount / 4} and {rowCount / 4 + 99}", + "select top 10 ID from bench.dbf order by ID desc", + "select count(*) from bench.dbf" + }; + + foreach (var sql in probes) + { + var indexed = Run(directory, sql, useIndexes: true); + var scanned = Run(directory, sql, useIndexes: false); + + if (!indexed.SequenceEqual(scanned)) + throw new InvalidOperationException($"index/scan mismatch for: {sql}"); + if (indexed.Count == 0) + throw new InvalidOperationException($"no rows for: {sql}"); + } + } + + private static List Run(string directory, string sql, bool useIndexes) + { + using var connection = new DbfDbConnection(); + connection.ConnectionString = + $"Folder={directory};SkipDeletedRecords=false;UseIndexes={useIndexes}"; + connection.Open(); + + var command = connection.CreateCommand(); + command.CommandText = sql; + + var rows = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var values = new object[reader.FieldCount]; + reader.GetValues(values); + rows.Add(string.Join("|", values)); + } + + return rows; + } + } +} diff --git a/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj b/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj index 17d9fcd..3b8ae7f 100644 --- a/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj +++ b/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj @@ -17,7 +17,7 @@ - + diff --git a/test/DbfDataReader.Benchmarks/IndexQueryBenchmarks.cs b/test/DbfDataReader.Benchmarks/IndexQueryBenchmarks.cs new file mode 100644 index 0000000..4cff3e1 --- /dev/null +++ b/test/DbfDataReader.Benchmarks/IndexQueryBenchmarks.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.IO; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace DbfDataReader.Benchmarks +{ + // Measures what automatic index use buys on a generated 50,000-row table with a + // compound index on ID (unique integers) and CODE (character keys, ~100 rows per + // value). Each pair runs the same SQL with UseIndexes on and off; the generated + // files are verified for index/scan equivalence before anything is measured. + [CategoriesColumn] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] + public class IndexQueryBenchmarks + { + private const int RowCount = 50_000; + + private string _directory = null!; + + [GlobalSetup] + public void Setup() + { + _directory = Path.Combine(Path.GetTempPath(), "DbfDataReader.Benchmarks", + $"index-{RowCount}"); + BenchmarkTableGenerator.Generate(_directory, RowCount); + BenchmarkTableGenerator.Verify(_directory, RowCount); + } + + [GlobalCleanup] + public void Cleanup() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + + [BenchmarkCategory("equality seek (1 row)")] + [Benchmark(Baseline = true, Description = "full scan")] + public int EqualitySeekFullScan() => Drain("select * from bench.dbf where ID = 25000", false); + + [BenchmarkCategory("equality seek (1 row)")] + [Benchmark(Description = "index")] + public int EqualitySeekWithIndex() => Drain("select * from bench.dbf where ID = 25000", true); + + [BenchmarkCategory("character seek (100 rows)")] + [Benchmark(Baseline = true, Description = "full scan")] + public int CharacterSeekFullScan() => Drain("select * from bench.dbf where CODE = 'C000123'", false); + + [BenchmarkCategory("character seek (100 rows)")] + [Benchmark(Description = "index")] + public int CharacterSeekWithIndex() => Drain("select * from bench.dbf where CODE = 'C000123'", true); + + [BenchmarkCategory("range scan (100 rows)")] + [Benchmark(Baseline = true, Description = "full scan")] + public int RangeFullScan() => + Drain("select ID, NAME from bench.dbf where ID between 25000 and 25099", false); + + [BenchmarkCategory("range scan (100 rows)")] + [Benchmark(Description = "index")] + public int RangeWithIndex() => + Drain("select ID, NAME from bench.dbf where ID between 25000 and 25099", true); + + [BenchmarkCategory("top 10 order by desc")] + [Benchmark(Baseline = true, Description = "scan + sort")] + public int TopTenDescendingScan() => + Drain("select top 10 ID, NAME from bench.dbf order by ID desc", false); + + [BenchmarkCategory("top 10 order by desc")] + [Benchmark(Description = "index")] + public int TopTenDescendingWithIndex() => + Drain("select top 10 ID, NAME from bench.dbf order by ID desc", true); + + [BenchmarkCategory("count(*) filtered (10k rows)")] + [Benchmark(Baseline = true, Description = "full scan")] + public int CountFilteredFullScan() => + Scalar("select count(*) from bench.dbf where ID between 10000 and 19999", false); + + [BenchmarkCategory("count(*) filtered (10k rows)")] + [Benchmark(Description = "index only")] + public int CountFilteredWithIndex() => + Scalar("select count(*) from bench.dbf where ID between 10000 and 19999", true); + + [BenchmarkCategory("count(*) all rows")] + [Benchmark(Baseline = true, Description = "read all rows")] + public int CountAllByReadingRows() + { + using var reader = new DbfDataReader(BenchmarkTableGenerator.DbfPath(_directory)); + var count = 0; + while (reader.Read()) count++; + return count; + } + + [BenchmarkCategory("count(*) all rows")] + [Benchmark(Description = "status scan")] + public int CountAllByStatusScan() => Scalar("select count(*) from bench.dbf", true); + + private int Drain(string sql, bool useIndexes) + { + using var connection = OpenConnection(useIndexes); + var command = connection.CreateCommand(); + command.CommandText = sql; + + var rows = 0; + var values = default(object[]); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + values ??= new object[reader.FieldCount]; + reader.GetValues(values); + rows++; + } + + return rows; + } + + private int Scalar(string sql, bool useIndexes) + { + using var connection = OpenConnection(useIndexes); + var command = connection.CreateCommand(); + command.CommandText = sql; + + return (int)command.ExecuteScalar()!; + } + + private DbfDbConnection OpenConnection(bool useIndexes) + { + var connection = new DbfDbConnection(); + connection.ConnectionString = + $"Folder={_directory};SkipDeletedRecords=false;UseIndexes={useIndexes}"; + connection.Open(); + return connection; + } + } +} From 56e9f2c33be1d5989fac8b035e89510978229b2e Mon Sep 17 00:00:00 2001 From: Chris Richards Date: Mon, 6 Jul 2026 16:57:38 +0100 Subject: [PATCH 2/3] Restore the cross-library comparison benchmark and record v1.1.0/v2.1.0 results Recreates the Sylvan / NDbf / DbfDataReader comparison in the style of MarkPflug/Benchmarks (reading every field of every record of the tl_2019_01_place census fixture), with the same method names as the historical v0.5.x tables. The DbfDataReader package version is now an MSBuild property (-p:DbfDataReaderVersion=x.y.z) so any release can be benchmarked; the 2.x-only index benchmarks are excluded from compilation for 1.x versions. A project README documents the commands. benchmarks.md gains v1.1.0 and v2.1.0 comparison sections: the sequential read path is unchanged between the two (~1.15x NDbf, identical allocations), confirming the SQL and index features are additive, and both improve markedly on the v0.5.x-era ratios. Co-Authored-By: Claude Fable 5 --- benchmarks.md | 28 +++++++++ .../DbfDataReader.Benchmarks.csproj | 18 +++++- .../DbfDataReaderBenchmarks.cs | 58 ++++++++++++++----- test/DbfDataReader.Benchmarks/README.md | 27 +++++++++ 4 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 test/DbfDataReader.Benchmarks/README.md diff --git a/benchmarks.md b/benchmarks.md index 15d8eb2..3a2673f 100644 --- a/benchmarks.md +++ b/benchmarks.md @@ -26,6 +26,34 @@ Outliers DbfDataReaderBenchmarks.NDbf: IterationTime=1.0000 s -> 2 outliers were removed (4.37 ms, 4.44 ms) +# v1.1.0 + +Cross-library comparison: read every field of every record of +`test/fixtures/tl_2019_01_place.dbf` (586 records), in the style of +[MarkPflug/Benchmarks](https://github.com/MarkPflug/Benchmarks/blob/main/source/Benchmarks/DbfDataReaderBenchmarks.cs). +Reproduce with: +`dotnet run -c Release --project test/DbfDataReader.Benchmarks -p:DbfDataReaderVersion=1.1.0 -- --filter "*DbfDataReaderBenchmarks*" --job short` + +BenchmarkDotNet v0.15.8, macOS (Apple M3 Max), .NET 10.0.2, Job=ShortRun +(numbers are not comparable with the v0.5.x tables above: different file and hardware) + +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | +|-------- |---------:|----------:|---------:|------:|--------:|--------:|--------:|----------:|------------:| +| Sylvan | 284.1 us | 39.52 us | 2.17 us | 0.54 | 0.02 | 41.0156 | 0.9766 | 336.81 KB | 0.80 | +| NDbf | 527.2 us | 429.13 us | 23.52 us | 1.00 | 0.05 | 51.7578 | 0.9766 | 423.52 KB | 1.00 | +| DbfData | 611.1 us | 40.72 us | 2.23 us | 1.16 | 0.04 | 85.9375 | 11.7188 | 704.41 KB | 1.66 | + +# v2.1.0 + +Same comparison against DbfDataReader 2.1.0 — the sequential read path is unchanged +from 1.1.0 (all of the SQL, typed-query and index features are additive): + +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | +|-------- |---------:|----------:|---------:|------:|--------:|--------:|--------:|----------:|------------:| +| Sylvan | 286.5 us | 88.50 us | 4.85 us | 0.51 | 0.01 | 41.0156 | 0.9766 | 336.81 KB | 0.80 | +| NDbf | 563.2 us | 207.53 us | 11.38 us | 1.00 | 0.02 | 51.7578 | 0.9766 | 423.52 KB | 1.00 | +| DbfData | 647.6 us | 384.16 us | 21.06 us | 1.15 | 0.04 | 85.9375 | 11.7188 | 704.41 KB | 1.66 | + # v2.1.0 — automatic index use Generated 50,000-row table (65-byte records) with a compound index on `ID` (unique diff --git a/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj b/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj index 3b8ae7f..4639151 100644 --- a/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj +++ b/test/DbfDataReader.Benchmarks/DbfDataReader.Benchmarks.csproj @@ -14,10 +14,23 @@ Release + + + 2.1.0 + + - + + + + + + + + + @@ -28,6 +41,9 @@ PreserveNewest + + PreserveNewest + diff --git a/test/DbfDataReader.Benchmarks/DbfDataReaderBenchmarks.cs b/test/DbfDataReader.Benchmarks/DbfDataReaderBenchmarks.cs index dd0c777..db956d3 100644 --- a/test/DbfDataReader.Benchmarks/DbfDataReaderBenchmarks.cs +++ b/test/DbfDataReader.Benchmarks/DbfDataReaderBenchmarks.cs @@ -1,29 +1,59 @@ -using System.Linq; +using System.IO; using BenchmarkDotNet.Attributes; +using NDbfReader; +using Sylvan.Data.XBase; namespace DbfDataReader.Benchmarks { + // Cross-library comparison in the style of + // https://github.com/MarkPflug/Benchmarks/blob/main/source/Benchmarks/DbfDataReaderBenchmarks.cs: + // read every field of every record of a US Census TIGER place file. Run against a + // specific released DbfDataReader with -p:DbfDataReaderVersion=x.y.z (see README). public class DbfDataReaderBenchmarks { - private const string FixturePath = "./fixtures/dbase_03.dbf"; + private const string FixturePath = "./fixtures/tl_2019_01_place.dbf"; [Benchmark] - public void DbfDataReader() + public void Sylvan() { - using (var dbfDataReader = new DbfDataReader(FixturePath)) + using var stream = File.OpenRead(FixturePath); + using var reader = XBaseDataReader.Create(stream); + while (reader.Read()) { - var cols = dbfDataReader.GetColumnSchema(); - var allowDbNull = cols.Select(c => c.AllowDBNull != false).ToArray(); - while (dbfDataReader.Read()) + for (var ordinal = 0; ordinal < reader.FieldCount; ordinal++) { - for (var ordinal = 0; ordinal < dbfDataReader.FieldCount; ordinal++) - { - if (allowDbNull[ordinal] && dbfDataReader.IsDBNull(ordinal)) - continue; - dbfDataReader.ReadField(ordinal); - } + if (reader.IsDBNull(ordinal)) continue; + reader.GetValue(ordinal); + } + } + } + + [Benchmark(Baseline = true)] + public void NDbf() + { + using var table = Table.Open(FixturePath); + var reader = table.OpenReader(); + while (reader.Read()) + { + foreach (var column in table.Columns) + { + reader.GetValue(column); + } + } + } + + [Benchmark] + public void DbfData() + { + using var dbfDataReader = new DbfDataReader(FixturePath); + while (dbfDataReader.Read()) + { + for (var ordinal = 0; ordinal < dbfDataReader.FieldCount; ordinal++) + { + if (dbfDataReader.IsDBNull(ordinal)) continue; + dbfDataReader.ReadField(ordinal); } } } } -} \ No newline at end of file +} diff --git a/test/DbfDataReader.Benchmarks/README.md b/test/DbfDataReader.Benchmarks/README.md new file mode 100644 index 0000000..de09ffb --- /dev/null +++ b/test/DbfDataReader.Benchmarks/README.md @@ -0,0 +1,27 @@ +# DbfDataReader.Benchmarks + +Benchmarks run against a **published DbfDataReader package** (default: the version in +the csproj). Results are recorded in [`../../benchmarks.md`](../../benchmarks.md). + +## Cross-library comparison (Sylvan / NDbf / DbfDataReader) + +Reads every field of every record of `fixtures/tl_2019_01_place.dbf`, in the style of +[MarkPflug/Benchmarks](https://github.com/MarkPflug/Benchmarks/blob/main/source/Benchmarks/DbfDataReaderBenchmarks.cs). +Run it against any released version to compare releases: + +``` +dotnet run -c Release --project test/DbfDataReader.Benchmarks -p:DbfDataReaderVersion=1.1.0 -- --filter "*DbfDataReaderBenchmarks*" --job short +dotnet run -c Release --project test/DbfDataReader.Benchmarks -p:DbfDataReaderVersion=2.1.0 -- --filter "*DbfDataReaderBenchmarks*" --job short +``` + +## Index benchmarks (2.x only) + +Generates a 50,000-row table with a compound index and pairs the same SQL with +`UseIndexes` on and off. The generated files are verified for index/scan equivalence +before anything is measured. + +``` +dotnet run -c Release --project test/DbfDataReader.Benchmarks -- --filter "*IndexQueryBenchmarks*" --job short +``` + +Omit `--job short` for full-accuracy runs. From f9547f3566e835c3ded4ce29c53bdeb9a6dfe871 Mon Sep 17 00:00:00 2001 From: Chris Richards Date: Mon, 6 Jul 2026 17:02:33 +0100 Subject: [PATCH 3/3] Ignore BenchmarkDotNet artifacts Co-Authored-By: Claude Fable 5 --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f0649aa..81c3fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ project.fragment.lock.json artifacts/ **/Properties/launchSettings.json /test/DbfDataReader.Benchmarks/BenchmarkDotNet.Artifacts/results -**/Properties/PublishProfiles \ No newline at end of file +**/Properties/PublishProfiles +# BenchmarkDotNet output +BenchmarkDotNet.Artifacts/