Skip to content

(Improvements) Rune operations optimization - #1252

Open
pl752 wants to merge 4 commits into
FirebirdSQL:masterfrom
pl752:rune_opt
Open

(Improvements) Rune operations optimization#1252
pl752 wants to merge 4 commits into
FirebirdSQL:masterfrom
pl752:rune_opt

Conversation

@pl752

@pl752pl752 commented Dec 29, 2025

Copy link
Copy Markdown
Contributor

Decided to split up my previous pull request (#1247) to multiple PRs due to it having set of changes too broad in my opinion. Ran tests as usual for fb3 server and embedded. Updated to current master branch state.

TLDR: Small set of optimizations, improving speed and alloc volumes of string methods tenfold (up to 3x if including I/O and real db engine for select queries containing char100 field in my case) and also including ability to better utilize modern processors SIMD instructions for BMP-only (single char per rune) strings.

Synthetic (serverless, no I/O) benchmarks

Implemented rune ops:

// * Summary *
BenchmarkDotNet v0.15.8, Windows 10 (10.0.19044.6691/21H2/November2021Update)
AMD Ryzen 7 5800H with Radeon Graphics 3.20GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.101
[Host] : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v3
Job-JMDAGQ : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v3
Job-OOTPKI : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v3
MethodJobToolchainKindRuneLengthMaxRuneCountMeanErrorStdDevGen0Allocated
'old truncate via EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Ascii1285122,490.0 ns39.22 ns36.69 ns1.04148712 B
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Ascii128512110.8 ns2.15 ns1.91 ns0.0334280 B
'old truncate via EnumerateRunesToChars'Job-OOTPKI.NET 8.0Ascii1285123,002.9 ns54.82 ns51.27 ns1.04148712 B
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Ascii128512108.2 ns1.94 ns1.81 ns0.0334280 B
'old truncate via EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Ascii10245129,842.2 ns196.62 ns201.91 ns4.058834056 B
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Ascii1024512376.1 ns4.77 ns4.46 ns0.12491048 B
'old truncate via EnumerateRunesToChars'Job-OOTPKI.NET 8.0Ascii102451211,843.6 ns178.52 ns166.99 ns4.058834056 B
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Ascii1024512423.3 ns7.58 ns6.72 ns0.12491048 B
'old truncate via EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]1285122,749.5 ns53.15 ns49.72 ns1.04908776 B
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]128512133.1 ns2.38 ns2.11 ns0.0410344 B
'old truncate via EnumerateRunesToChars'Job-OOTPKI.NET 8.0Mixed(...)gates [21]1285123,315.5 ns61.21 ns57.26 ns1.04908776 B
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Mixed(...)gates [21]128512126.6 ns1.03 ns0.91 ns0.0410344 B
'old truncate via EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]102451210,957.8 ns157.33 ns139.47 ns4.089434312 B
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]1024512453.9 ns8.40 ns7.86 ns0.15541304 B
'old truncate via EnumerateRunesToChars'Job-OOTPKI.NET 8.0Mixed(...)gates [21]102451212,647.8 ns113.42 ns100.54 ns4.089434312 B
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Mixed(...)gates [21]1024512449.2 ns5.65 ns5.01 ns0.15541304 B
'old truncate via EnumerateRunesToChars'Job-JMDAGQ.NET 10.0MostlySurrogates1285123,225.2 ns28.44 ns25.21 ns1.06818936 B
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0MostlySurrogates128512165.1 ns3.34 ns5.67 ns0.0601504 B
'old truncate via EnumerateRunesToChars'Job-OOTPKI.NET 8.0MostlySurrogates1285123,867.0 ns40.20 ns37.61 ns1.06818936 B
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0MostlySurrogates128512161.0 ns3.25 ns7.84 ns0.0601504 B
'old truncate via EnumerateRunesToChars'Job-JMDAGQ.NET 10.0MostlySurrogates102451213,368.1 ns239.05 ns211.91 ns4.165634952 B
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0MostlySurrogates1024512584.4 ns10.15 ns9.00 ns0.23171944 B
'old truncate via EnumerateRunesToChars'Job-OOTPKI.NET 8.0MostlySurrogates102451215,874.9 ns213.65 ns199.85 ns4.165634952 B
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0MostlySurrogates1024512592.7 ns11.31 ns10.58 ns0.23171944 B
MethodJobToolchainKindRuneLengthMeanErrorStdDevMedianGen0Allocated
'old Count() over EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Ascii128619.63 ns11.920 ns11.707 ns620.95 ns0.50354216 B
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Ascii12864.97 ns0.332 ns0.294 ns64.97 ns--
'old Count() over EnumerateRunesToChars'Job-OOTPKI.NET 8.0Ascii128754.45 ns14.865 ns31.678 ns745.93 ns0.50354216 B
'new CountRunes(span)'Job-OOTPKI.NET 8.0Ascii12866.31 ns0.209 ns0.196 ns66.30 ns--
'old Count() over EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Ascii819235,601.63 ns693.533 ns1,886.808 ns34,675.31 ns31.3110262264 B
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Ascii81923,909.67 ns23.210 ns21.711 ns3,901.30 ns--
'old Count() over EnumerateRunesToChars'Job-OOTPKI.NET 8.0Ascii819246,619.68 ns920.902 ns1,752.112 ns46,408.94 ns31.3110262264 B
'new CountRunes(span)'Job-OOTPKI.NET 8.0Ascii81923,924.67 ns15.360 ns14.368 ns3,921.15 ns--
'old Count() over EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]128649.58 ns11.920 ns11.150 ns651.50 ns0.50354216 B
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]12874.12 ns1.125 ns0.939 ns74.00 ns--
'old Count() over EnumerateRunesToChars'Job-OOTPKI.NET 8.0Mixed(...)gates [21]128745.95 ns14.848 ns14.583 ns749.10 ns0.50354216 B
'new CountRunes(span)'Job-OOTPKI.NET 8.0Mixed(...)gates [21]12881.77 ns0.479 ns0.374 ns81.83 ns--
'old Count() over EnumerateRunesToChars'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]819238,241.41 ns592.292 ns462.423 ns38,313.08 ns31.3110262264 B
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]81924,373.03 ns11.984 ns10.623 ns4,372.00 ns--
'old Count() over EnumerateRunesToChars'Job-OOTPKI.NET 8.0Mixed(...)gates [21]819245,407.27 ns876.842 ns1,140.142 ns45,305.08 ns31.3110262264 B
'new CountRunes(span)'Job-OOTPKI.NET 8.0Mixed(...)gates [21]81924,841.84 ns13.875 ns12.300 ns4,844.33 ns--
'old Count() over EnumerateRunesToChars'Job-JMDAGQ.NET 10.0MostlySurrogates128715.61 ns13.934 ns12.352 ns718.27 ns0.50354216 B
'new CountRunes(span)'Job-JMDAGQ.NET 10.0MostlySurrogates12892.80 ns0.554 ns0.492 ns92.63 ns--
'old Count() over EnumerateRunesToChars'Job-OOTPKI.NET 8.0MostlySurrogates128814.53 ns16.191 ns31.579 ns796.44 ns0.50354216 B
'new CountRunes(span)'Job-OOTPKI.NET 8.0MostlySurrogates12897.37 ns0.291 ns0.258 ns97.33 ns--
'old Count() over EnumerateRunesToChars'Job-JMDAGQ.NET 10.0MostlySurrogates819243,324.08 ns835.526 ns781.551 ns43,488.72 ns31.3110262264 B
'new CountRunes(span)'Job-JMDAGQ.NET 10.0MostlySurrogates81925,585.16 ns25.642 ns21.412 ns5,584.12 ns--
'old Count() over EnumerateRunesToChars'Job-OOTPKI.NET 8.0MostlySurrogates819250,161.87 ns1,000.371 ns1,466.331 ns50,378.08 ns31.3110262264 B
'new CountRunes(span)'Job-OOTPKI.NET 8.0MostlySurrogates81925,831.10 ns30.611 ns27.135 ns5,827.66 ns--

Added SIMD search and early bailout:

Added SIMD search:

MethodJobToolchainKindRuneLengthMaxRuneCountMeanErrorStdDevMedianRatioRatioSDGen0AllocatedAlloc Ratio
'prev TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Ascii128512117.83 ns3.247 ns9.472 ns118.01 ns1.010.110.0334280 B1.00
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Ascii12851220.53 ns1.192 ns3.458 ns21.20 ns0.180.030.0335280 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Ascii128512113.01 ns2.309 ns5.836 ns112.96 ns1.000.070.0334280 B1.00
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Ascii12851217.22 ns0.272 ns0.302 ns17.14 ns0.150.010.0335280 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Ascii1024512356.61 ns4.256 ns3.772 ns356.42 ns1.000.010.12491048 B1.00
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Ascii102451258.19 ns1.418 ns4.024 ns57.35 ns0.160.010.12531048 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Ascii1024512447.18 ns8.996 ns23.540 ns454.22 ns1.000.080.12491048 B1.00
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Ascii102451279.19 ns5.037 ns14.853 ns77.34 ns0.180.030.12531048 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]128512128.13 ns3.666 ns10.808 ns126.82 ns1.010.120.0410344 B1.00
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]12851216.93 ns0.387 ns0.791 ns16.80 ns0.130.010.0411344 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Mixed(...)gates [21]128512121.79 ns2.492 ns5.523 ns120.67 ns1.000.060.0410344 B1.00
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Mixed(...)gates [21]12851218.89 ns0.391 ns0.858 ns18.66 ns0.160.010.0411344 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]1024512436.43 ns8.777 ns10.449 ns439.27 ns1.000.030.15541304 B1.00
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]1024512376.56 ns7.524 ns14.675 ns374.07 ns0.860.040.15541304 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Mixed(...)gates [21]1024512422.26 ns5.899 ns5.229 ns423.12 ns1.000.020.15541304 B1.00
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0Mixed(...)gates [21]1024512393.42 ns5.567 ns4.649 ns392.34 ns0.930.020.15541304 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0MostlySurrogates128512150.52 ns2.176 ns1.817 ns150.22 ns1.000.020.0601504 B1.00
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0MostlySurrogates12851221.68 ns0.482 ns1.296 ns20.99 ns0.140.010.0602504 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0MostlySurrogates128512154.18 ns3.114 ns6.361 ns155.56 ns1.000.060.0601504 B1.00
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0MostlySurrogates12851223.86 ns0.502 ns1.390 ns23.22 ns0.160.010.0602504 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0MostlySurrogates1024512556.12 ns10.914 ns11.208 ns556.83 ns1.000.030.23171944 B1.00
'new TruncateStringToRuneCount().ToString()'Job-JMDAGQ.NET 10.0MostlySurrogates1024512492.66 ns9.859 ns9.222 ns490.17 ns0.890.020.23171944 B1.00
'prev TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0MostlySurrogates1024512545.49 ns8.397 ns7.444 ns544.45 ns1.000.020.23171944 B1.00
'new TruncateStringToRuneCount().ToString()'Job-OOTPKI.NET 8.0MostlySurrogates1024512557.46 ns10.655 ns10.464 ns556.98 ns1.020.020.23171944 B1.00
MethodJobToolchainKindRuneLengthMeanErrorStdDevRatioRatioSDAllocatedAlloc Ratio
'prev CountRunes(span)'Job-JMDAGQ.NET 10.0Ascii12870.216 ns1.3830 ns2.3484 ns1.000.05-NA
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Ascii1284.904 ns0.0343 ns0.0321 ns0.070.00-NA
'prev CountRunes(span)'Job-OOTPKI.NET 8.0Ascii12868.014 ns1.3878 ns1.4849 ns1.000.03-NA
'new CountRunes(span)'Job-OOTPKI.NET 8.0Ascii1286.129 ns0.0646 ns0.0605 ns0.090.00-NA
'prev CountRunes(span)'Job-JMDAGQ.NET 10.0Ascii81923,884.546 ns31.2551 ns29.2360 ns1.000.01-NA
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Ascii8192254.202 ns3.0534 ns2.5497 ns0.070.00-NA
'prev CountRunes(span)'Job-OOTPKI.NET 8.0Ascii81924,064.459 ns42.3702 ns37.5601 ns1.000.01-NA
'new CountRunes(span)'Job-OOTPKI.NET 8.0Ascii8192254.313 ns4.4622 ns4.1739 ns0.060.00-NA
'prev CountRunes(span)'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]12874.100 ns0.8221 ns0.7690 ns1.000.01-NA
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]12877.034 ns1.5091 ns1.8533 ns1.040.03-NA
'prev CountRunes(span)'Job-OOTPKI.NET 8.0Mixed(...)gates [21]12885.025 ns1.7147 ns2.4037 ns1.000.04-NA
'new CountRunes(span)'Job-OOTPKI.NET 8.0Mixed(...)gates [21]12885.963 ns0.7137 ns0.5960 ns1.010.03-NA
'prev CountRunes(span)'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]81924,396.650 ns58.2950 ns54.5292 ns1.000.02-NA
'new CountRunes(span)'Job-JMDAGQ.NET 10.0Mixed(...)gates [21]81924,434.144 ns22.8985 ns21.4193 ns1.010.01-NA
'prev CountRunes(span)'Job-OOTPKI.NET 8.0Mixed(...)gates [21]81924,904.013 ns41.1963 ns34.4008 ns1.000.01-NA
'new CountRunes(span)'Job-OOTPKI.NET 8.0Mixed(...)gates [21]81924,822.738 ns31.6198 ns29.5771 ns0.980.01-NA
'prev CountRunes(span)'Job-JMDAGQ.NET 10.0MostlySurrogates12894.261 ns0.9110 ns0.7607 ns1.000.01-NA
'new CountRunes(span)'Job-JMDAGQ.NET 10.0MostlySurrogates12899.823 ns1.4972 ns1.4005 ns1.060.02-NA
'prev CountRunes(span)'Job-OOTPKI.NET 8.0MostlySurrogates12896.153 ns0.3468 ns0.3074 ns1.000.00-NA
'new CountRunes(span)'Job-OOTPKI.NET 8.0MostlySurrogates128101.584 ns1.0409 ns0.8692 ns1.060.01-NA
'prev CountRunes(span)'Job-JMDAGQ.NET 10.0MostlySurrogates81925,546.374 ns25.6919 ns21.4539 ns1.000.01-NA
'new CountRunes(span)'Job-JMDAGQ.NET 10.0MostlySurrogates81925,574.675 ns79.9365 ns70.8616 ns1.010.01-NA
'prev CountRunes(span)'Job-OOTPKI.NET 8.0MostlySurrogates81925,686.914 ns37.1821 ns31.0488 ns1.000.01-NA
'new CountRunes(span)'Job-OOTPKI.NET 8.0MostlySurrogates81925,720.962 ns76.7373 ns71.7802 ns1.010.01-NA

Synthetic benchmarks demonstrate order of magnitude (or two in case of BMP-only and AVX512 in theory) improvements for methods themselves, also count and span truncation itself are alloc-free now.

Practical benchmarks (3 int and 1 char100 columns, real I/O with fb3 server on localhost and nvme)

Master:

MethodRowsMeanErrorStdDevMedianGen0Gen1Allocated
SelectAndMap_Main_ReusedBufferAsync101.796 ms0.0649 ms0.1904 ms1.784 ms--457.27 KB
SelectAndMap_Main_ReusedBufferAsync1008.326 ms0.5724 ms1.5955 ms7.472 ms--4502.13 KB
SelectAndMap_Main_ReusedBufferAsync100031.037 ms3.7512 ms10.8231 ms24.973 ms5000.00001000.000044985.72 KB
SelectAndMap_Main_ReusedBufferAsync10000337.278 ms16.5461 ms48.7865 ms334.254 ms55000.000010000.0000449543.01 KB
SelectAndMap_Main_ReusedBufferAsync1000003,467.848 ms98.9785 ms288.7249 ms3,435.619 ms550000.0000114000.00004494685.62 KB

New:

MethodRowsMeanErrorStdDevGen0Gen1Allocated
SelectAndMap_Main_ReusedBufferAsync10676.7 us32.22 us93.99 us--59.62 KB
SelectAndMap_Main_ReusedBufferAsync1002,851.1 us89.50 us252.43 us--537.37 KB
SelectAndMap_Main_ReusedBufferAsync100026,382.1 us4,842.76 us14,279.00 us--5145.7 KB
SelectAndMap_Main_ReusedBufferAsync10000265,738.7 us19,378.44 us56,833.63 us6000.00001000.000051357.16 KB
SelectAndMap_Main_ReusedBufferAsync1000002,529,994.5 us124,955.52 us366,472.93 us62000.000010000.0000513368.48 KB

In practice differences are still noticiable, up to 3x in terms of speed, tenfold in memory volume and ~100x in allocation events reduction can be observed (profiling of real run, not in benchmark).

UPD: Fixed duplicated enumeration and found out, I somehow missed few places of old way of counting while splitting commits

@fdcastel

Copy link
Copy Markdown
Member

Hey @pl752! Great work!

I’ve been working on a refactor for this project’s benchmarks on #1203

Do you think we could combine some of your benchmark changes with it?

I’d really appreciate any thoughts or suggestions you have 😄.

@pl752

Copy link
Copy Markdown
ContributorAuthor

@fdcastel Hello, I am sorry that I haven't replied initially, I was busy and forgot about the notification. I unfortunately don't have any particular suggestions about benchmarks, because my tests are either written specific to my project queries or they are very local synthetic tests of the thing I was working with and not very representative. I appreciate your changes for benchmarks as currently there is a lack of convenient tools of performance assesment here.

@pl752

Copy link
Copy Markdown
ContributorAuthor

Also recent force push was due to addition of gpg signatures to my commits.

@fdcastel

Copy link
Copy Markdown
Member

@pl752 two minor suggestions for this PR (found while investigating #1272):

  • After the PR, EnumerateRunesToChars (Extensions.cs) is dead code -- could be removed.
  • PR adds classic this ReadOnlySpan<char> extension methods; current master refactored Extensions.cs to C# extension(...) blocks.

@fdcastelfdcastel mentioned this pull request May 23, 2026
@pl752

pl752 commented May 24, 2026

Copy link
Copy Markdown
ContributorAuthor

@fdcastel Thank you, applied your suggestions (new syntax and dead helper removal), I think PR is ready for review

Sign up for freeto 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

@pl752@fdcastel