perf(mtconnect): sample chunks parse from bytes, no per-chunk string copies

This commit is contained in:
Joseph Doherty
2026-08-16 04:05:15 -04:00
parent 3795899d10
commit 34f836e03c
3 changed files with 215 additions and 7 deletions
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Xml;
using System.Xml.Linq;
@@ -121,6 +122,33 @@ internal static class MTConnectStreamsParser
return Build(document);
}
/// <summary>
/// Parses one <c>/sample</c> chunk straight from the bytes the multipart framer produced.
/// </summary>
/// <remarks>
/// The <c>/sample</c> hot path used to materialise every chunk twice — once as a decoded
/// string, once again as the trimmed copy handed to the parser — for a document that is read
/// once and thrown away. Parsing from the bytes drops both copies; <see cref="XDocument"/>
/// does its own UTF-8 decoding off the stream. The framing tolerance the string overload's
/// <c>Trim</c> provided is preserved by <see cref="TrimFraming"/>, which slices rather than
/// copies.
/// </remarks>
/// <param name="xml">The chunk's raw bytes; a UTF-8 BOM and surrounding framing whitespace are tolerated.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
public static MTConnectStreamsResult Parse(ReadOnlyMemory<byte> xml)
{
var trimmed = TrimFraming(xml);
if (trimmed.IsEmpty)
{
throw new InvalidDataException(
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
}
using var stream = AsStream(trimmed);
return Parse(stream);
}
/// <summary>Parses a <c>/current</c> response read from a stream.</summary>
/// <param name="stream">A stream positioned at the start of the XML document.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
@@ -141,6 +169,55 @@ internal static class MTConnectStreamsParser
return Build(document);
}
/// <summary>
/// Slices off what the string overload's <c>Trim('', ' ', '\t', '\r', '\n')</c> removed:
/// a UTF-8 byte-order mark and the multipart framing's surrounding line breaks, either of
/// which <see cref="XmlReader"/> rejects ahead of the XML declaration.
/// </summary>
private static ReadOnlyMemory<byte> TrimFraming(ReadOnlyMemory<byte> xml)
{
var span = xml.Span;
var start = 0;
var end = span.Length;
while (start < end)
{
if (end - start >= 3 && span[start] == 0xEF && span[start + 1] == 0xBB && span[start + 2] == 0xBF)
{
start += 3;
continue;
}
if (!IsFramingWhitespace(span[start]))
{
break;
}
start++;
}
while (end > start && IsFramingWhitespace(span[end - 1]))
{
end--;
}
return xml[start..end];
}
private static bool IsFramingWhitespace(byte value) =>
value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n';
/// <summary>
/// Views the (already trimmed) bytes as a stream without copying them where the memory is
/// array-backed — which it always is on the <c>/sample</c> path, whose framer hands over the
/// part's own <c>byte[]</c>.
/// </summary>
private static MemoryStream AsStream(ReadOnlyMemory<byte> xml) =>
MemoryMarshal.TryGetArray(xml, out ArraySegment<byte> segment) && segment.Array is not null
? new MemoryStream(segment.Array, segment.Offset, segment.Count, writable: false)
: new MemoryStream(xml.ToArray(), writable: false);
private static MTConnectStreamsResult Build(XDocument document)
{
var root = document.Root