From: Atsushi Kanamori Date: Tue, 20 Mar 2018 21:14:41 +0000 (-0700) Subject: Port System.Memory performance improvements to Convert.TryFromBase64Chars (#17033) X-Git-Tag: accepted/tizen/unified/20190422.045933~2572 X-Git-Url: http://review.tizen.org/git/?a=commitdiff_plain;h=e0fa11308de58684cde6bf8b7d0da287b48f01e9;p=platform%2Fupstream%2Fcoreclr.git Port System.Memory performance improvements to Convert.TryFromBase64Chars (#17033) * Copy over Base64.DecodeFromUtf8 verbatim * Don't need the InPlace api * Change input from Utf8 to Utf16 * isFinalBlock will always be true, so constant fold it through * I only need a true/false result, so get rid of OperationStatus * Rename variables to reflect Utf8->Utf16 change * Update comments and some minor cleanup * Make Convert use Base64 as a fast-path * Incorporate PR feedback. * Delete FromBase64_Decode * Use a single safe stackalloc * Rename Base64.cs to Convert.Base64.cs and make its contents a part of the Convert class. We should eventually move all the Base64-related stuff into this new file but not as part of this PR. We want to be able to diff the implementation change separately from the code movement. * Thanks VS for the unasked for indent --- diff --git a/src/mscorlib/shared/System.Private.CoreLib.Shared.projitems b/src/mscorlib/shared/System.Private.CoreLib.Shared.projitems index 2bc6464..270d06d 100644 --- a/src/mscorlib/shared/System.Private.CoreLib.Shared.projitems +++ b/src/mscorlib/shared/System.Private.CoreLib.Shared.projitems @@ -94,6 +94,7 @@ + diff --git a/src/mscorlib/shared/System/Convert.Base64.cs b/src/mscorlib/shared/System/Convert.Base64.cs new file mode 100644 index 0000000..7e2aee3 --- /dev/null +++ b/src/mscorlib/shared/System/Convert.Base64.cs @@ -0,0 +1,217 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Internal.Runtime.CompilerServices; + +namespace System +{ + public static partial class Convert + { + /// + /// Decode the span of UTF-16 encoded text represented as base 64 into binary data. + /// If the input is not a multiple of 4, or contains illegal characters, it will decode as much as it can, to the largest possible multiple of 4. + /// This invariant allows continuation of the parse with a slower, whitespace-tolerant algorithm. + /// + /// The input span which contains UTF-16 encoded text in base 64 that needs to be decoded. + /// The output span which contains the result of the operation, i.e. the decoded binary data. + /// The number of input bytes consumed during the operation. This can be used to slice the input for subsequent calls, if necessary. + /// The number of bytes written into the output span. This can be used to slice the output for subsequent calls, if necessary. + /// Returns: + /// - true - The entire input span was successfully parsed. + /// - false - Only a part of the input span was successfully parsed. Failure causes may include embedded or trailing whitespace, + /// other illegal Base64 characters, trailing characters after an encoding pad ('='), an input span whose length is not divisible by 4 + /// or a destination span that's too small. and are set so that + /// parsing can continue with a slower whitespace-tolerant algorithm. + /// + /// Note: This is a cut down version of the implementation of Base64.DecodeFromUtf8(), modified the accept UTF16 chars and act as a fast-path + /// helper for the Convert routines when the input string contains no whitespace. + /// + /// + private static bool TryDecodeFromUtf16(ReadOnlySpan utf16, Span bytes, out int consumed, out int written) + { + ref char srcChars = ref MemoryMarshal.GetReference(utf16); + ref byte destBytes = ref MemoryMarshal.GetReference(bytes); + + int srcLength = utf16.Length & ~0x3; // only decode input up to the closest multiple of 4. + int destLength = bytes.Length; + + int sourceIndex = 0; + int destIndex = 0; + + if (utf16.Length == 0) + goto DoneExit; + + ref sbyte decodingMap = ref s_decodingMap[0]; + + // Last bytes could have padding characters, so process them separately and treat them as valid. + const int skipLastChunk = 4; + + int maxSrcLength; + if (destLength >= (srcLength >> 2) * 3) + { + maxSrcLength = srcLength - skipLastChunk; + } + else + { + // This should never overflow since destLength here is less than int.MaxValue / 4 * 3 (i.e. 1610612733) + // Therefore, (destLength / 3) * 4 will always be less than 2147483641 + maxSrcLength = (destLength / 3) * 4; + } + + while (sourceIndex < maxSrcLength) + { + int result = Decode(ref Unsafe.Add(ref srcChars, sourceIndex), ref decodingMap); + if (result < 0) + goto InvalidExit; + WriteThreeLowOrderBytes(ref Unsafe.Add(ref destBytes, destIndex), result); + destIndex += 3; + sourceIndex += 4; + } + + if (maxSrcLength != srcLength - skipLastChunk) + goto InvalidExit; + + // If input is less than 4 bytes, srcLength == sourceIndex == 0 + // If input is not a multiple of 4, sourceIndex == srcLength != 0 + if (sourceIndex == srcLength) + { + goto InvalidExit; + } + + int i0 = Unsafe.Add(ref srcChars, srcLength - 4); + int i1 = Unsafe.Add(ref srcChars, srcLength - 3); + int i2 = Unsafe.Add(ref srcChars, srcLength - 2); + int i3 = Unsafe.Add(ref srcChars, srcLength - 1); + if (((i0 | i1 | i2 | i3) & 0xffffff00) != 0) + goto InvalidExit; + + i0 = Unsafe.Add(ref decodingMap, i0); + i1 = Unsafe.Add(ref decodingMap, i1); + + i0 <<= 18; + i1 <<= 12; + + i0 |= i1; + + if (i3 != EncodingPad) + { + i2 = Unsafe.Add(ref decodingMap, i2); + i3 = Unsafe.Add(ref decodingMap, i3); + + i2 <<= 6; + + i0 |= i3; + i0 |= i2; + + if (i0 < 0) + goto InvalidExit; + if (destIndex > destLength - 3) + goto InvalidExit; + WriteThreeLowOrderBytes(ref Unsafe.Add(ref destBytes, destIndex), i0); + destIndex += 3; + } + else if (i2 != EncodingPad) + { + i2 = Unsafe.Add(ref decodingMap, i2); + + i2 <<= 6; + + i0 |= i2; + + if (i0 < 0) + goto InvalidExit; + if (destIndex > destLength - 2) + goto InvalidExit; + Unsafe.Add(ref destBytes, destIndex) = (byte)(i0 >> 16); + Unsafe.Add(ref destBytes, destIndex + 1) = (byte)(i0 >> 8); + destIndex += 2; + } + else + { + if (i0 < 0) + goto InvalidExit; + if (destIndex > destLength - 1) + goto InvalidExit; + Unsafe.Add(ref destBytes, destIndex) = (byte)(i0 >> 16); + destIndex += 1; + } + + sourceIndex += 4; + + if (srcLength != utf16.Length) + goto InvalidExit; + + DoneExit: + consumed = sourceIndex; + written = destIndex; + return true; + + InvalidExit: + consumed = sourceIndex; + written = destIndex; + Debug.Assert((consumed % 4) == 0); + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int Decode(ref char encodedChars, ref sbyte decodingMap) + { + int i0 = encodedChars; + int i1 = Unsafe.Add(ref encodedChars, 1); + int i2 = Unsafe.Add(ref encodedChars, 2); + int i3 = Unsafe.Add(ref encodedChars, 3); + + if (((i0 | i1 | i2 | i3) & 0xffffff00) != 0) + return -1; // One or more chars falls outside the 00..ff range. This cannot be a valid Base64 character. + + i0 = Unsafe.Add(ref decodingMap, i0); + i1 = Unsafe.Add(ref decodingMap, i1); + i2 = Unsafe.Add(ref decodingMap, i2); + i3 = Unsafe.Add(ref decodingMap, i3); + + i0 <<= 18; + i1 <<= 12; + i2 <<= 6; + + i0 |= i3; + i1 |= i2; + + i0 |= i1; + return i0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteThreeLowOrderBytes(ref byte destination, int value) + { + destination = (byte)(value >> 16); + Unsafe.Add(ref destination, 1) = (byte)(value >> 8); + Unsafe.Add(ref destination, 2) = (byte)value; + } + + // Pre-computing this table using a custom string(s_characters) and GenerateDecodingMapAndVerify (found in tests) + private static readonly sbyte[] s_decodingMap = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, //62 is placed at index 43 (for +), 63 at index 47 (for /) + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, //52-61 are placed at index 48-57 (for 0-9), 64 at index 61 (for =) + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, //0-25 are placed at index 65-90 (for A-Z) + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, //26-51 are placed at index 97-122 (for a-z) + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // Bytes over 122 ('z') are invalid and cannot be decoded + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // Hence, padding the map with 255, which indicates invalid input + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + private const byte EncodingPad = (byte)'='; // '=', for padding + } +} diff --git a/src/mscorlib/shared/System/Convert.cs b/src/mscorlib/shared/System/Convert.cs index 756bf17..63342ad 100644 --- a/src/mscorlib/shared/System/Convert.cs +++ b/src/mscorlib/shared/System/Convert.cs @@ -96,7 +96,7 @@ namespace System // When passed Value.DBNull, the Value.ToXXX() methods all throw an // InvalidCastException. - public static class Convert + public static partial class Convert { //A typeof operation is fairly expensive (does a system call), so we'll cache these here //statically. These are exactly lined up with the TypeCode, eg. ConvertType[TypeCode.Int16] @@ -2656,44 +2656,124 @@ namespace System return TryFromBase64Chars(s.AsSpan(), bytes, out bytesWritten); } - public static unsafe bool TryFromBase64Chars(ReadOnlySpan chars, Span bytes, out int bytesWritten) + public static bool TryFromBase64Chars(ReadOnlySpan chars, Span bytes, out int bytesWritten) { - if (chars.Length == 0) - { - bytesWritten = 0; - return true; - } + // This is actually local to one of the nested blocks but is being declared at the top as we don't want multiple stackallocs + // for each iteraton of the loop. + Span tempBuffer = stackalloc char[4]; // Note: The tempBuffer size could be made larger than 4 but the size must be a multiple of 4. - // We need to get rid of any trailing white spaces. - // Otherwise we would be rejecting input such as "abc= ": - while (chars.Length > 0) + bytesWritten = 0; + + while (chars.Length != 0) { - char lastChar = chars[chars.Length - 1]; - if (lastChar != ' ' && lastChar != '\n' && lastChar != '\r' && lastChar != '\t') + // Attempt to decode a segment that doesn't contain whitespace. + bool complete = TryDecodeFromUtf16(chars, bytes, out int consumedInThisIteration, out int bytesWrittenInThisIteration); + bytesWritten += bytesWrittenInThisIteration; + if (complete) + return true; + + chars = chars.Slice(consumedInThisIteration); + bytes = bytes.Slice(bytesWrittenInThisIteration); + + Debug.Assert(chars.Length != 0); // If TryDecodeFromUtf16() consumed the entire buffer, it could not have returned false. + if (chars[0].IsSpace()) { - break; - } - chars = chars.Slice(0, chars.Length - 1); - } + // If we got here, the very first character not consumed was a whitespace. We can skip past any consecutive whitespace, then continue decoding. - fixed (char* charsPtr = &MemoryMarshal.GetReference(chars)) - { - int resultLength = FromBase64_ComputeResultLength(charsPtr, chars.Length); - Debug.Assert(resultLength >= 0); - if (resultLength > bytes.Length) + int indexOfFirstNonSpace = 1; + for (; ; ) + { + if (indexOfFirstNonSpace == chars.Length) + break; + if (!chars[indexOfFirstNonSpace].IsSpace()) + break; + indexOfFirstNonSpace++; + } + + chars = chars.Slice(indexOfFirstNonSpace); + + if ((bytesWrittenInThisIteration % 3) != 0 && chars.Length != 0) + { + // If we got here, the last successfully decoded block encountered an end-marker, yet we have trailing non-whitespace characters. + // That is not allowed. + bytesWritten = default; + return false; + } + + // We now loop again to decode the next run of non-space characters. + } + else { - bytesWritten = 0; - return false; + Debug.Assert(chars.Length != 0 && !chars[0].IsSpace()); + + // If we got here, it is possible that there is whitespace that occurred in the middle of a 4-byte chunk. That is, we still have + // up to three Base64 characters that were left undecoded by the fast-path helper because they didn't form a complete 4-byte chunk. + // This is hopefully the rare case (multiline-formatted base64 message with a non-space character width that's not a multiple of 4.) + // We'll filter out whitespace and copy the remaining characters into a temporary buffer. + CopyToTempBufferWithoutWhiteSpace(chars, tempBuffer, out int consumedFromChars, out int charsWritten); + if ((charsWritten & 0x3) != 0) + { + // Even after stripping out whitespace, the number of characters is not divisible by 4. This cannot be a legal Base64 string. + bytesWritten = default; + return false; + } + + tempBuffer = tempBuffer.Slice(0, charsWritten); + if (!TryDecodeFromUtf16(tempBuffer, bytes, out int consumedFromTempBuffer, out int bytesWrittenFromTempBuffer)) + { + bytesWritten = default; + return false; + } + bytesWritten += bytesWrittenFromTempBuffer; + chars = chars.Slice(consumedFromChars); + bytes = bytes.Slice(bytesWrittenFromTempBuffer); + + if ((bytesWrittenFromTempBuffer % 3) != 0) + { + // If we got here, this decode contained one or more padding characters ('='). We can accept trailing whitespace after this + // but nothing else. + for (int i = 0; i < chars.Length; i++) + { + if (!chars[i].IsSpace()) + { + bytesWritten = default; + return false; + } + } + return true; + } + + // We now loop again to decode the next run of non-space characters. } + } - fixed (byte* bytesPtr = &MemoryMarshal.GetReference(bytes)) + return true; + } + + private static void CopyToTempBufferWithoutWhiteSpace(ReadOnlySpan chars, Span tempBuffer, out int consumed, out int charsWritten) + { + Debug.Assert(tempBuffer.Length != 0); // We only bound-check after writing a character to the tempBuffer. + + charsWritten = 0; + for (int i = 0; i < chars.Length; i++) + { + char c = chars[i]; + if (!c.IsSpace()) { - bytesWritten = FromBase64_Decode(charsPtr, chars.Length, bytesPtr, bytes.Length); - return true; + tempBuffer[charsWritten++] = c; + if (charsWritten == tempBuffer.Length) + { + consumed = i + 1; + return; + } } } + consumed = chars.Length; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsSpace(this char c) => c == ' ' || c == '\t' || c == '\r' || c == '\n'; + /// /// Converts the specified range of a Char array, which encodes binary data as Base64 digits, to the equivalent byte array. /// @@ -2730,8 +2810,6 @@ namespace System } } - - /// /// Convert Base64 encoding characters to bytes: /// - Compute result length exactly by actually walking the input; @@ -2769,11 +2847,10 @@ namespace System Byte[] decodedBytes = new Byte[resultLength]; // Convert Base64 chars into bytes: - Int32 actualResultLength; - fixed (Byte* decodedBytesPtr = decodedBytes) - actualResultLength = FromBase64_Decode(inputPtr, inputLength, decodedBytesPtr, resultLength); + if (!TryFromBase64Chars(new ReadOnlySpan(inputPtr, inputLength), decodedBytes, out int _)) + throw new FormatException(SR.Format_BadBase64Char); - // Note that actualResultLength can differ from resultLength if the caller is modifying the array + // Note that the number of bytes written can differ from resultLength if the caller is modifying the array // as it is being converted. Silently ignore the failure. // Consider throwing exception in an non in-place release. @@ -2781,199 +2858,6 @@ namespace System return decodedBytes; } - - /// - /// Decode characters representing a Base64 encoding into bytes: - /// Walk the input. Every time 4 chars are read, convert them to the 3 corresponding output bytes. - /// This method is a bit lengthy on purpose. We are trying to avoid jumps to helpers in the loop - /// to aid performance. - /// - /// Pointer to first input char - /// Number of input chars - /// Pointer to location for the first result byte - /// Max length of the preallocated result buffer - /// If the result buffer was not large enough to write all result bytes, return -1; - /// Otherwise return the number of result bytes actually produced. - private static unsafe Int32 FromBase64_Decode(Char* startInputPtr, Int32 inputLength, Byte* startDestPtr, Int32 destLength) - { - // You may find this method weird to look at. It's written for performance, not aesthetics. - // You will find unrolled loops label jumps and bit manipulations. - - const UInt32 intA = (UInt32)'A'; - const UInt32 inta = (UInt32)'a'; - const UInt32 int0 = (UInt32)'0'; - const UInt32 intEq = (UInt32)'='; - const UInt32 intPlus = (UInt32)'+'; - const UInt32 intSlash = (UInt32)'/'; - const UInt32 intSpace = (UInt32)' '; - const UInt32 intTab = (UInt32)'\t'; - const UInt32 intNLn = (UInt32)'\n'; - const UInt32 intCRt = (UInt32)'\r'; - const UInt32 intAtoZ = (UInt32)('Z' - 'A'); // = ('z' - 'a') - const UInt32 int0to9 = (UInt32)('9' - '0'); - - Char* inputPtr = startInputPtr; - Byte* destPtr = startDestPtr; - - // Pointers to the end of input and output: - Char* endInputPtr = inputPtr + inputLength; - Byte* endDestPtr = destPtr + destLength; - - // Current char code/value: - UInt32 currCode; - - // This 4-byte integer will contain the 4 codes of the current 4-char group. - // Eeach char codes for 6 bits = 24 bits. - // The remaining byte will be FF, we use it as a marker when 4 chars have been processed. - UInt32 currBlockCodes = 0x000000FFu; - - unchecked - { - while (true) - { - // break when done: - if (inputPtr >= endInputPtr) - goto _AllInputConsumed; - - // Get current char: - currCode = (UInt32)(*inputPtr); - inputPtr++; - - // Determine current char code: - - if (currCode - intA <= intAtoZ) - currCode -= intA; - - else if (currCode - inta <= intAtoZ) - currCode -= (inta - 26u); - - else if (currCode - int0 <= int0to9) - currCode -= (int0 - 52u); - - else - { - // Use the slower switch for less common cases: - switch (currCode) - { - // Significant chars: - case intPlus: - currCode = 62u; - break; - - case intSlash: - currCode = 63u; - break; - - // Legal no-value chars (we ignore these): - case intCRt: - case intNLn: - case intSpace: - case intTab: - continue; - - // The equality char is only legal at the end of the input. - // Jump after the loop to make it easier for the JIT register predictor to do a good job for the loop itself: - case intEq: - goto _EqualityCharEncountered; - - // Other chars are illegal: - default: - throw new FormatException(SR.Format_BadBase64Char); - } - } - - // Ok, we got the code. Save it: - currBlockCodes = (currBlockCodes << 6) | currCode; - - // Last bit in currBlockCodes will be on after in shifted right 4 times: - if ((currBlockCodes & 0x80000000u) != 0u) - { - if ((Int32)(endDestPtr - destPtr) < 3) - return -1; - - *(destPtr) = (Byte)(currBlockCodes >> 16); - *(destPtr + 1) = (Byte)(currBlockCodes >> 8); - *(destPtr + 2) = (Byte)(currBlockCodes); - destPtr += 3; - - currBlockCodes = 0x000000FFu; - } - } - } // unchecked while - - // 'd be nice to have an assert that we never get here, but CS0162: Unreachable code detected. - // Debug.Fail("We only leave the above loop by jumping; should never get here."); - - // We jump here out of the loop if we hit an '=': - _EqualityCharEncountered: - - Debug.Assert(currCode == intEq); - - // Recall that inputPtr is now one position past where '=' was read. - // '=' can only be at the last input pos: - if (inputPtr == endInputPtr) - { - // Code is zero for trailing '=': - currBlockCodes <<= 6; - - // The '=' did not complete a 4-group. The input must be bad: - if ((currBlockCodes & 0x80000000u) == 0u) - throw new FormatException(SR.Format_BadBase64CharArrayLength); - - if ((int)(endDestPtr - destPtr) < 2) // Autch! We underestimated the output length! - return -1; - - // We are good, store bytes form this past group. We had a single "=", so we take two bytes: - *(destPtr++) = (Byte)(currBlockCodes >> 16); - *(destPtr++) = (Byte)(currBlockCodes >> 8); - - currBlockCodes = 0x000000FFu; - } - else - { // '=' can also be at the pre-last position iff the last is also a '=' excluding the white spaces: - // We need to get rid of any intermediate white spaces. - // Otherwise we would be rejecting input such as "abc= =": - while (inputPtr < (endInputPtr - 1)) - { - Int32 lastChar = *(inputPtr); - if (lastChar != (Int32)' ' && lastChar != (Int32)'\n' && lastChar != (Int32)'\r' && lastChar != (Int32)'\t') - break; - inputPtr++; - } - - if (inputPtr == (endInputPtr - 1) && *(inputPtr) == '=') - { - // Code is zero for each of the two '=': - currBlockCodes <<= 12; - - // The '=' did not complete a 4-group. The input must be bad: - if ((currBlockCodes & 0x80000000u) == 0u) - throw new FormatException(SR.Format_BadBase64CharArrayLength); - - if ((Int32)(endDestPtr - destPtr) < 1) // Autch! We underestimated the output length! - return -1; - - // We are good, store bytes form this past group. We had a "==", so we take only one byte: - *(destPtr++) = (Byte)(currBlockCodes >> 16); - - currBlockCodes = 0x000000FFu; - } - else // '=' is not ok at places other than the end: - throw new FormatException(SR.Format_BadBase64Char); - } - - // We get here either from above or by jumping out of the loop: - _AllInputConsumed: - - // The last block of chars has less than 4 items - if (currBlockCodes != 0x000000FFu) - throw new FormatException(SR.Format_BadBase64CharArrayLength); - - // Return how many bytes were actually recovered: - return (Int32)(destPtr - startDestPtr); - } // Int32 FromBase64_Decode(...) - - /// /// Compute the number of bytes encoded in the specified Base 64 char array: /// Walk the entire input counting white spaces and padding chars, then compute result length