Skip to content

Improve performance reading basic strings processing - #110

Merged
prozolic merged 1 commit into
mainfrom
doublequotesinglestring
Jul 11, 2026
Merged

Improve performance reading basic strings processing#110
prozolic merged 1 commit into
mainfrom
doublequotesinglestring

Conversation

@prozolic

Copy link
Copy Markdown
Owner

This PR improves performance by changing a process based on Span<T> to a process using SIMD operations with SearchValues.IndexOfAny.
I have also added test in CsTomlReader.ReadDoubleQuoteSingleLineStringSlow.

Benchmark

Method Length Escaped Mean Error StdDev Median Ratio RatioSD BranchInstructions/Op BranchMispredictions/Op Code Size Allocated Alloc Ratio
PerByteIfChain 16 False 24.634 ns 2.5788 ns 3.7800 ns 25.854 ns 1.02 0.21 84 0 272 B - NA
StopSearchValuesBulkCopy 16 False 7.579 ns 0.5605 ns 0.8216 ns 7.669 ns 0.31 0.06 21 0 1,495 B - NA
StopSearchValuesNoCopy 16 False 7.496 ns 2.1189 ns 3.1058 ns 9.712 ns 0.31 0.14 9 0 669 B - NA
StopSearchValuesNoCopyAt 16 False 7.888 ns 2.3079 ns 3.3099 ns 8.884 ns 0.33 0.14 8 0 654 B - NA
PerByteIfChain 16 True 26.275 ns 2.1176 ns 3.1695 ns 25.480 ns 1.01 0.17 78 0 271 B - NA
StopSearchValuesBulkCopy 16 True 27.771 ns 1.2466 ns 1.8658 ns 27.722 ns 1.07 0.14 76 0 1,564 B - NA
StopSearchValuesNoCopy 16 True 31.147 ns 1.8695 ns 2.7402 ns 31.070 ns 1.20 0.17 76 0 2,109 B - NA
StopSearchValuesNoCopyAt 16 True 29.880 ns 1.2435 ns 1.7833 ns 29.973 ns 1.15 0.15 75 0 2,094 B - NA
PerByteIfChain 256 False 416.734 ns 31.6929 ns 47.4364 ns 419.079 ns 1.01 0.16 1,290 1 272 B - NA
StopSearchValuesBulkCopy 256 False 19.606 ns 0.7630 ns 1.1420 ns 19.473 ns 0.05 0.01 44 0 1,627 B - NA
StopSearchValuesNoCopy 256 False 12.769 ns 0.7697 ns 1.1521 ns 12.388 ns 0.03 0.00 25 0 672 B - NA
StopSearchValuesNoCopyAt 256 False 13.115 ns 0.8262 ns 1.2366 ns 13.086 ns 0.03 0.00 24 0 659 B - NA
PerByteIfChain 256 True 466.165 ns 39.6227 ns 59.3054 ns 467.914 ns 1.02 0.18 1,285 3 271 B - NA
StopSearchValuesBulkCopy 256 True 35.249 ns 3.1163 ns 4.6643 ns 34.577 ns 0.08 0.01 94 0 1,663 B - NA
StopSearchValuesNoCopy 256 True 40.396 ns 1.3345 ns 1.9561 ns 40.210 ns 0.09 0.01 96 0 2,183 B - NA
StopSearchValuesNoCopyAt 256 True 42.210 ns 4.5985 ns 6.7405 ns 38.633 ns 0.09 0.02 95 0 2,170 B - NA
Benchmark source
public class BasicStringScan
{
    private const byte DoubleQuote = 0x22;
    private const byte BackSlash = 0x5C;

    // control chars + '"' + '\' (34 values)
    private static readonly SearchValues<byte> StopBytes = CreateStopBytes();

    // control chars only (32 values) — post-validation for the 2-value IndexOfAny candidate
    private static readonly SearchValues<byte> ControlBytes = CreateControlBytes();

    private static SearchValues<byte> CreateStopBytes()
    {
        Span<byte> stops = stackalloc byte[34];
        var n = 0;
        for (var b = 0x00; b <= 0x08; b++) { stops[n++] = (byte)b; }
        for (var b = 0x0A; b <= 0x1F; b++) { stops[n++] = (byte)b; }
        stops[n++] = 0x7F;
        stops[n++] = DoubleQuote;
        stops[n++] = BackSlash;
        return SearchValues.Create(stops[..n]);
    }

    private static SearchValues<byte> CreateControlBytes()
    {
        Span<byte> ctrl = stackalloc byte[32];
        var n = 0;
        for (var b = 0x00; b <= 0x08; b++) { ctrl[n++] = (byte)b; }
        for (var b = 0x0A; b <= 0x1F; b++) { ctrl[n++] = (byte)b; }
        ctrl[n++] = 0x7F;
        return SearchValues.Create(ctrl[..n]);
    }

    private byte[] buffer = [];
    private readonly LabWriter writer = new(1024);

    [Params(16, 256)]
    public int Length { get; set; }

    [Params(false, true)]
    public bool Escaped { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        // realistic ASCII content, closing quote, then trailing TOML
        ReadOnlySpan<byte> chars = "The quick brown fox jumps over the lazy dog 0123456789. "u8;
        ReadOnlySpan<byte> tail = "\r\nnext = 1"u8;
        buffer = new byte[Length + 1 + tail.Length];
        for (var i = 0; i < Length; i++)
        {
            buffer[i] = chars[i % chars.Length];
        }
        if (Escaped)
        {
            // two backslash escape pairs inside the content
            buffer[Length / 3] = BackSlash;
            buffer[Length / 3 + 1] = (byte)'n';
            buffer[Length * 2 / 3] = BackSlash;
            buffer[Length * 2 / 3 + 1] = (byte)'n';
        }
        buffer[Length] = DoubleQuote;
        tail.CopyTo(buffer.AsSpan(Length + 1));
    }

    // --- current implementation (CsTomlReader.ReadDoubleQuoteSingleLineString) ---

    // replica of TomlCodes.IsEscape: U+0000-U+0008, U+000A-U+001F, U+007F
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static bool IsEscape(byte rawByte)
    {
        ReadOnlySpan<bool> escapeTable =
        [
            true, true, true, true, true, true, true, true, true, false, true, true, true, true, true, true,                 // 0x00 - 0x0f
            true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true,                  // 0x10 - 0x1f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0x20 - 0x2f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0x30 - 0x3f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0x40 - 0x4f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0x50 - 0x5f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0x60 - 0x6f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true,   // 0x70 - 0x7f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0x80 - 0x8f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0x90 - 0x9f
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0xa0 - 0xaf
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0xb0 - 0xbf
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0xc0 - 0xcf
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0xd0 - 0xdf
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0xe0 - 0xef
            false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,  // 0xf0 - 0xff
        ];
        return Unsafe.Add(ref MemoryMarshal.GetReference(escapeTable), rawByte);
    }

    // Escape handling is simplified for all candidates alike: on '\', append the byte
    // after it and consume 2 bytes (stands in for ParseEscapeSequence, identical cost
    // across candidates so the comparison stays focused on the scan itself).

    [MethodImpl(MethodImplOptions.NoInlining)]
    [Benchmark(Baseline = true)]
    public int PerByteIfChain()
    {
        var writer = this.writer;
        writer.Reset();
        var span = buffer.AsSpan();
        for (var index = 0; index < span.Length; index++)
        {
            var ch = span[index];
            if (IsEscape(ch))
            {
                ThrowInvalid(ch);
            }
            else if (ch == DoubleQuote)
            {
                return writer.WrittenCount;
            }
            else if (ch == BackSlash)
            {
                writer.Write(span[index + 1]);
                index++;
                continue;
            }
            writer.Write(ch);
        }
        ThrowUnclosed();
        return -1;
    }

    // --- candidates ---

    [MethodImpl(MethodImplOptions.NoInlining)]
    [Benchmark]
    public int StopSearchValuesBulkCopy()
    {
        var writer = this.writer;
        writer.Reset();
        var span = buffer.AsSpan();
        while (true)
        {
            var idx = span.IndexOfAny(StopBytes);
            if (idx < 0)
            {
                ThrowUnclosed();
            }
            var ch = span[idx];
            if (ch == DoubleQuote)
            {
                writer.Write(span[..idx]);
                return writer.WrittenCount;
            }
            if (ch != BackSlash)
            {
                ThrowInvalid(ch);
            }
            writer.Write(span[..idx]);
            writer.Write(span[idx + 1]);
            span = span[(idx + 2)..];
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    [Benchmark]
    public int QuoteBackslashThenValidate()
    {
        var writer = this.writer;
        writer.Reset();
        var span = buffer.AsSpan();
        while (true)
        {
            var idx = span.IndexOfAny(DoubleQuote, BackSlash);
            if (idx < 0)
            {
                ThrowUnclosed();
            }
            var content = span[..idx];
            if (content.ContainsAny(ControlBytes))
            {
                ThrowInvalid(0);
            }
            writer.Write(content);
            if (span[idx] == DoubleQuote)
            {
                return writer.WrittenCount;
            }
            writer.Write(span[idx + 1]);
            span = span[(idx + 2)..];
        }
    }

    // Fast path for the dominant case (no backslash before the closing quote):
    // the content is one clean slice, so skip the buffer writer entirely.
    // In production this means calling T.Parse(unreadSpan[..idx]) directly.
    [MethodImpl(MethodImplOptions.NoInlining)]
    [Benchmark]
    public int StopSearchValuesNoCopy()
    {
        var span = buffer.AsSpan();
        var idx = span.IndexOfAny(StopBytes);
        if (idx < 0)
        {
            ThrowUnclosed();
        }
        var ch = span[idx];
        if (ch == DoubleQuote)
        {
            return idx; // content length; production: T.Parse(span[..idx]), no writer rented
        }
        if (ch != BackSlash)
        {
            ThrowInvalid(ch);
        }
        return NoCopySlowPath(span, idx);
    }

    // Same as StopSearchValuesNoCopy but reads the stop byte via Unsafe.Add — the bounds
    // check after IndexOfAny (asm: cmp/jae -> CORINFO_HELP_RNGCHKFAIL) is provably redundant.
    // Matches the production shape, which uses span.At(idx).
    [MethodImpl(MethodImplOptions.NoInlining)]
    [Benchmark]
    public int StopSearchValuesNoCopyAt()
    {
        var span = buffer.AsSpan();
        var idx = span.IndexOfAny(StopBytes);
        if (idx < 0)
        {
            ThrowUnclosed();
        }
        var ch = Unsafe.Add(ref MemoryMarshal.GetReference(span), idx);
        if (ch == DoubleQuote)
        {
            return idx; // content length; production: T.Parse(span[..idx]), no writer rented
        }
        if (ch != BackSlash)
        {
            ThrowInvalid(ch);
        }
        return NoCopySlowPath(span, idx);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private int NoCopySlowPath(ReadOnlySpan<byte> span, int idx)
    {
        // from the first escape onwards, same as StopSearchValuesBulkCopy
        var writer = this.writer;
        writer.Reset();
        while (true)
        {
            writer.Write(span[..idx]);
            writer.Write(span[idx + 1]);
            span = span[(idx + 2)..];
            idx = span.IndexOfAny(StopBytes);
            if (idx < 0)
            {
                ThrowUnclosed();
            }
            var ch = span[idx];
            if (ch == DoubleQuote)
            {
                writer.Write(span[..idx]);
                return writer.WrittenCount;
            }
            if (ch != BackSlash)
            {
                ThrowInvalid(ch);
            }
        }
    }

    // replicates ArrayPoolBufferWriter<byte> write shapes (capacity check + Unsafe.Add store);
    // capacity is pre-sized so the grow path never runs, mirroring the rented-1024 steady state.
    private sealed class LabWriter(int capacity)
    {
        private readonly byte[] buffer = new byte[capacity];
        private int index;

        public int WrittenCount => index;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void Reset() => index = 0;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void Write(byte value)
        {
            if ((uint)index >= (uint)buffer.Length)
            {
                ThrowFull();
            }
            Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(buffer), index++) = value;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void Write(ReadOnlySpan<byte> value)
        {
            if ((uint)value.Length > (uint)(buffer.Length - index))
            {
                ThrowFull();
            }
            value.CopyTo(buffer.AsSpan(index, value.Length));
            index += value.Length;
        }

        [DoesNotReturn]
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static void ThrowFull()
            => throw new InvalidOperationException("LabWriter capacity exceeded.");
    }

    [DoesNotReturn]
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void ThrowInvalid(byte ch)
        => throw new InvalidOperationException($"Invalid byte in basic string: 0x{ch:X2}");

    [DoesNotReturn]
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void ThrowUnclosed()
        => throw new InvalidOperationException("Basic string is not closed.");
}

This PR improves performance by changing a process based on `Span<T>` to a process using SIMD operations with `SearchValues.IndexOfAny`.
I have also added test in `CsTomlReader.ReadDoubleQuoteSingleLineStringSlow`.
Copilot AI review requested due to automatic review settings July 11, 2026 11:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes parsing of double-quoted single-line (basic) TOML strings by switching from per-byte scanning to a vectorized scan using SearchValues<byte>.IndexOfAny, preserving correctness via slow-path handling when escapes or segment boundaries occur.

Changes:

  • Add TomlCodes.BasicStringStopChars to support SIMD-friendly stop-byte scanning for basic strings.
  • Update CsTomlReader.ReadDoubleQuoteSingleLineString to use a fast-path slice parse when possible and a refactored bulk-copy slow path otherwise.
  • Add new tests covering slow-path behavior across multi-segment ReadOnlySequence<byte> inputs (including escapes, control bytes, and UTF-8 boundary cases).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
tests/CsToml.Tests/BasicStringSlowPathTest.cs Adds coverage for multi-segment slow-path parsing scenarios (escapes, control chars, UTF-8 split, unterminated).
src/CsToml/TomlCodes.cs Introduces a reusable SearchValues<byte> stop set for vectorized basic-string scanning.
src/CsToml/CsTomlReader.cs Implements the new vectorized scan fast path and updates the slow path to bulk-copy clean runs efficiently.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/CsToml/TomlCodes.cs
Comment thread src/CsToml/TomlCodes.cs
@prozolic
prozolic merged commit 15e8e38 into main Jul 11, 2026
2 checks passed
@prozolic
prozolic deleted the doublequotesinglestring branch July 11, 2026 11:46
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