Skip to content

Improve Binary Xml (XmlDictionaryWriter) performance - #71478

Merged
StephenMolloy merged 30 commits into
dotnet:mainfrom
Daniel-Svensson:dev
Apr 1, 2023
Merged

Improve Binary Xml (XmlDictionaryWriter) performance #71478
StephenMolloy merged 30 commits into
dotnet:mainfrom
Daniel-Svensson:dev

Conversation

@Daniel-Svensson

@Daniel-SvenssonDaniel-Svensson commented Jun 30, 2022

Copy link
Copy Markdown
Contributor

Improves performance the binary XmlDictionaryWriter mainly by using Span and Unsafe

  • Do larger writes using Unsafe instead of writing bytes at a time and avoid bounds checks for each byte written
  • Use Span instead of pointers in a few places
    • For array writes the usage of Spans means that it is no longer necessary to do extra copies separate byte arrays
  • Remove allocations when writing Guids
  • Behaviour change: 32bit floats (and doubles) will no longer be encoded as 64bit (or 32bit) int. Instead it will only ecode it as an int if it saves space.

Other:

  • I did not find the tests for the Binary XmlDictionaryWriter so instead I did some manual tests to ensure double and long round tripped as well as ran OpenRiaServices tests (which includes tests roundtriping classes with most property types with the changes on the server
  • update 2022-07-25: added tests cases for binary xml
  • I considered adding a tests to verify that (float)(1L << 63) is serialized as float instead of long, if you think it should be added then please advice where such a test should be written.
  • I also did som experriments just using Span and Cast instead of Unsafe but the improvements was smaller compared to using Unsafe (ratio 0.78 for span and 0.54 for Unsafe for calling WriteInt64Text directly with memorystream backing).

Benchmarks

"Before" is the official net7 preview.5 bits (with r2r) while After is local release build of System.Xml and "System.Private.DataContract..* dll.

*Benchmarks Code:*

usingBenchmarkDotNet.Attributes;usingSystem;usingSystem.Diagnostics;usingSystem.IO;usingSystem.Threading;usingSystem.Threading.Tasks;usingSystem.Xml;namespaceConsoleApp1;[MemoryDiagnoser]publicclassBinaryXmlBenchmarks{privateXmlDictionaryWriter_writer;privateGuid_guid=Guid.NewGuid();constintArraySize=100;privateGuid[]_guids=Enumerable.Repeat(Guid.NewGuid(),ArraySize).ToArray();privateDouble[]_doubles=Enumerable.Repeat(4.0/3.0,ArraySize).ToArray();[GlobalSetup]publicvoidSetup(){_writer=XmlDictionaryWriter.CreateBinaryWriter(newNullWriteStream());_writer.WriteStartDocument();_writer.WriteStartElement("root");varasm=_writer.GetType().Assembly;varversion=FileVersionInfo.GetVersionInfo(asm.Location);Console.WriteLine($"Assembly: {asm.Location}");Console.WriteLine($"Version: {version.FileVersion}");}[Benchmark]publicvoidWriteInt32(){_writer.WriteValue(unchecked((int)0xdeadbeef));}[Benchmark]publicvoidWriteInt64(){_writer.WriteValue(long.MaxValue);}[Benchmark]publicvoidWriteDouble(){_writer.WriteValue(1.0/3.0);}[Benchmark]publicvoidWriteDecimal(){_writer.WriteValue(1.2m);}[Benchmark]publicvoidWriteGuid(){_writer.WriteValue(_guid);}[Benchmark]publicvoidWriteGuidArray(){_writer.WriteArray(null,"a",null,_guids,0,_guids.Length);}[Benchmark]publicvoidWriteDoubleArray(){_writer.WriteArray(null,"a",null,_doubles,0,_doubles.Length);}}internalclassNullWriteStream:Stream{publicoverrideboolCanRead=>false;publicoverrideboolCanSeek=>false;publicoverrideboolCanWrite=>true;publicoverridelongLength=>thrownewNotSupportedException();publicoverridelongPosition{get=>thrownewNotSupportedException();set=>thrownewNotSupportedException();}publicoverridevoidFlush(){}publicoverrideintRead(byte[]buffer,intoffset,intcount){thrownewNotSupportedException();}publicoverridelongSeek(longoffset,SeekOriginorigin){thrownewNotSupportedException();}publicoverridevoidSetLength(longvalue){thrownewNotSupportedException();}publicoverridevoidWrite(byte[]buffer,intoffset,intcount){}publicoverridevoidWrite(ReadOnlySpan<byte>buffer){}publicoverrideTaskWriteAsync(byte[]buffer,intoffset,intcount,CancellationTokencancellationToken){returnTask.CompletedTask;}publicoverridevoidWriteByte(bytevalue){}publicoverrideValueTaskWriteAsync(ReadOnlyMemory<byte>buffer,CancellationTokencancellationToken=default){returnValueTask.CompletedTask;}}

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
AMD Ryzen 5 5600U with Radeon Graphics, 1 CPU, 12 logical and 6 physical cores
.NET SDK=7.0.100-preview.5.22307.18
[Host] : .NET 7.0.0 (7.0.22.30112), X64 RyuJIT
After : .NET 7.0.0 (7.0.22.30112), X64 RyuJIT
7.0-preview5 : .NET 7.0.0 (7.0.22.30112), X64 RyuJIT
MaxRelativeError=0.01 IterationTime=250.0000 ms 
MethodJobMeanErrorStdDevMedianRatioRatioSDTotalIssues/OpTotalCycles/OpInstructionRetired/OpTimer/OpGen 0Allocated
WriteInt327.0-preview516.26 ns0.186 ns0.207 ns16.21 ns1.000.008829950--
WriteInt32Unsafe13.55 ns0.149 ns0.204 ns13.52 ns0.840.026924740--
WriteInt32Span14.06 ns0.163 ns0.326 ns13.99 ns0.850.037925740-
WriteInt647.0-preview521.66 ns0.237 ns0.446 ns21.57 ns1.000.00117411260--
WriteInt64Unsafe12.45 ns0.150 ns0.125 ns12.47 ns0.580.016225660--
WriteInt64Span13.05 ns0.154 ns0.183 ns13.02 ns0.600.017424690--
WriteDouble7.0-preview524.43 ns0.261 ns0.290 ns24.35 ns1.000.00120471280--
WriteDoubleUnsafe14.15 ns0.162 ns0.233 ns14.19 ns0.580.016826720--
WriteDoubleSpan17.00 ns0.682 ns1.958 ns16.34 ns0.640.047930740--
WriteDecimal7.0-preview543.56 ns0.688 ns1.918 ns42.75 ns1.000.00249772700--
WriteDecimalUnsafe20.44 ns0.219 ns0.234 ns20.40 ns0.440.019140960--
WriteDecimalSpan21.81 ns0.173 ns0.200 ns21.82 ns0.500.0110345970--
WriteGuid7.0-preview545.66 ns0.451 ns0.646 ns45.69 ns1.000.002098922400.004740 B
WriteGuid*Unsafe*19.75 ns0.180 ns0.159 ns19.80 ns0.430.018239860--
WriteGuid*Span*16.90 ns0.189 ns0.266 ns16.84 ns0.380.018831830--
WriteTimespan7.0-preview522.05 ns0.219 ns0.195 ns22.01 ns1.000.00119401280--
WriteTimespanUnsafe12.59 ns0.150 ns0.307 ns12.59 ns0.560.016123650--
WriteTimespanSpan14.49 ns0.167 ns0.245 ns14.49 ns0.650.017228680--
WriteGuidArray7.0-preview53,417.86 ns33.230 ns34.125 ns3,426.72 ns1.000.0016,1526,65417,066340.47044,000 B
WriteGuidArrayUnsafe595.89 ns5.973 ns7.767 ns594.17 ns0.180.003,1531,1163,4146--
WriteGuidArraySpan614.04 ns5.926 ns6.824 ns612.00 ns0.180.003,4171,1673,1676--
WriteDoubleArray7.0-preview51,040.62 ns10.192 ns10.467 ns1,039.25 ns1.000.005,9541,8966,50410--
WriteDoubleArrayUnsafe130.87 ns1.337 ns1.830 ns131.20 ns0.130.006662497151--
WriteDoubleArraySpan130.76 ns1.242 ns1.526 ns130.69 ns0.130.007152496661--

Remarks: * The span and unsafe version of WriteGuid and Write...Array are identical so I do not know why there is a timing differecnce.

@ghostghost added area-Serialization community-contribution Indicates that the PR has been added by a community member labels Jun 30, 2022
}

public unsafe override void WriteText(char[] chars, int offset, int count)
private unsafe void WriteText(Span<char> chars)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I did try using ReadOnlySpan but then it looked like to many methods called this overload instead of the string version so I used span instead to avoid extra string allocations.

However it might be possibly to merge this implementation with the string based method if using ReadOnlyMemory instead since it allows trying to retrieve the original string.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

many methods called this overload instead of the string version

That does not sound right. If you have a string and there is a string overload, the compiler should pick the string overload.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I suspect might have been a VisualStudio bug, but I did not want to take any chances in case it had anything to do with one method being an override or similar.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

changed the code, will have to decompile the code and have a second look. Visual studio (17.2) still reports many references to the ReadOnlySpan version

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The unexpected calls are still there in the compiled assembly, should I keep it?
You might know who to involve to find out why it is called instead of the string overload.

image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, this surprises me:
SharpLab
For some reason the WriteText(string) overload being an override is causing it to lose out to the span-based overload.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I solved this by creating a separate "implementation metod" which is not an override (I called it WriteTextImpl to follow what seems to be the current naming convention for implementation methods)

_textNodeOffset = this.BufferOffset - 1;
}

private ref byte GetBufferRef(int size)

@jkotasjkotasJun 30, 2022

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This code was using safe C# with proper boundschecks. You are switching this to use unsafe code without boundschecks. It is increasing security risk profile of this code.

Can this stay with safe C# and Spans? You should be still able to get nice performance gains by doing that.

@Daniel-SvenssonDaniel-SvenssonJun 30, 2022

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Yes there was quite much unsafe code and points present in the file so I assumed it was ok to be unsafe for the best performance sine GetBuffer does the bounds check.

I've refactored the code so that all new "unsafe" code is now only present in 2 methods to make it much easier to reason about and updated the benchmark with timings for both Unsafe and Span.

The span version does .AsSpan(offset, size) on the buffer and then uses MemoryMarshal.Write<T> which I just foud out about.. I hope that method is considered safe enough and works well with "unaligned writes".

Please have a look and let me know based on the new code and the benchmark results which version you want.
I can push the updated span version instead if that version is the prefered one.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Note: A side effect of moving to the span version is that it will probably be necessary to keep separate implementationa for writing 8bit (and maybe 16bit) nodes in order to not regress performance for them.

Some benchmarks would be required to determine the performance numbers for smaller elements first

@Daniel-Svensson
Daniel-Svenssonforce-pushed the dev branch 2 times, most recently from 4d56543 to d1e850bCompareJune 30, 2022 16:49

@stephentoubstephentoub left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there sufficient test coverage to fully exercise all of these changes and root out any corner-case issues?

Co-authored-by: Stephen Toub <stoub@microsoft.com>
@Daniel-Svensson

Daniel-Svensson commented Jul 8, 2022

Copy link
Copy Markdown
ContributorAuthor

I think I fixed all the review comments for now so I think next up it to determine what to do with tests.

Is there sufficient test coverage to fully exercise all of these changes and root out any corner-case issues?

@stephentoub I am a bit unsure about that.

I did not find the tests for the Binary XmlDictionaryWriter so instead I did some manual tests to ensure double and long round tripped as well as ran OpenRiaServices tests (which includes tests roundtriping classes with most property types with the changes on the server

I can look into adding a new tests I found some tests for the text version of dictionary writer I could write some tests for the binary writer (write primitive types and some arrays ?).
I am thinking about serializing some data and then checking the binary representation against expected binary, or do you prefer round trip kind tests (where the same data is read by binary reader)?

update: I've added a couple of testcases so I think relevant scenarios are covered.

@Daniel-Svensson
Daniel-Svenssonforce-pushed the dev branch 2 times, most recently from 5da3b9e to 963315dCompareJuly 25, 2022 19:00
@Daniel-Svensson

Daniel-Svensson commented Jul 25, 2022

Copy link
Copy Markdown
ContributorAuthor

I've added a couple of tests that I think cover relevant cases.

I was a bit unsure how to handle any potential big endian platform since it is only handled for single integers so the array test as it is now will fail on big endian platforms. it can easily be made to pass but it will not make the binary xml the same for different endiannes (this pr does not make any changes to the representation apart from the fix for floats described in the description).

Daniel-Svenssonand others added 2 commits August 16, 2022 12:10
…nOnly/System.Runtime.Serialization.Xml.ReflectionOnly.Tests.csproj
@Daniel-Svensson

Daniel-Svensson commented Aug 24, 2022

Copy link
Copy Markdown
ContributorAuthor

The test will give problem on big-endian architectures just as @uweigand found out #73332 (comment)

I can take a look and add a commit to this PR (maybe even this week) to try to fix the implementation for big endian architectures if you think it is a good idea? @jkotas@mconnew

If you prefer this PR as is without any behavioral changes is there a recommended way of skipping the "problematic" test on big endian architectures?

@HongGit

Copy link
Copy Markdown
Contributor

@StephenMolloy and @mconnew can you please take a look?

@StephenMolloy

Copy link
Copy Markdown
Member

Test failure appears to be unrelated. #64227

@StephenMolloyStephenMolloy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@StephenMolloyStephenMolloy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@StephenMolloy
StephenMolloy merged commit 390c2d5 into dotnet:mainApr 1, 2023
@Daniel-Svensson
Daniel-Svensson deleted the dev branch April 5, 2023 17:45
@ghostghost locked as resolved and limited conversation to collaborators May 5, 2023
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Serializationcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@Daniel-Svensson@HongGit@StephenMolloy@stephentoub@jkotas@ts-223