This library is a small collection of specialized helper types which primarily focus on reducing allocations compared to BCL alternatives. This is NOT a general-purpose library. If you are not concerned with managed heap allocations, you will likely be better off sticking to BCL-provided types and methods.
- StopwatchStruct: A value-type implementation of Stopwatch for benchmarking without allocations.
- ReusableStream: A Stream implementation which reads from a byte array, and:
- Allows the underlying data array to be swapped out without allocating a new wrapper stream.
- Integrates with StringSet to reduce allocations when reading strings from a byte stream.
- Provides helper methods similar to BinaryReader and BinaryWriter.
- Provides several unsafe methods for reading and writing from byte pointers.
- StringSet: A specialized hash set for strings which allows lookups by hash code.
- UnsafeStringComparer: A set of string-comparison methods where one or more operands are a char array or pointer.
- UnsafeMd5: An MD5 hashing implementation which performs zero allocations.
- Unsafe: a small collection of unsafe static utility methods.
- MemoryCopy: A quick alternative to
memcpyfor small data buffers (~400 bytes or less). - ToHexString: Returns the hexadecimal representation of an unmanaged buffer.
- MemoryCopy: A quick alternative to
StopwatchStruct is a partial re-implementation of the Stopwatch class with methods for Start(), Stop(), and GetElapsedMilliseconds() plus an Elapsed property.
This struct will only work on Windows because it relies on calls to QueryPerformanceCounter for high-resolution time measurements.
The main advantage over the Stopwatch class is that StopwatchStruct is a value type, and therefore can be allocated on the stack, where it won't incur any garbage collection
varsw=newStopwatchStruct();sw.Start();DoSomething();sw.Stop();doublems=sw.GetElapsedMilliseconds();// orTimeSpanelapsed=sw.Elapsed;StopwatchStruct is intended to be started and stopped within a single method. It's generally not recommended to pass the struct to other methods or return it unless you understand all the implications of pass-by-value for a mutable struct.
The primary use case for ReusableStream is when you want to read or write binary data to a byte array. A MemoryStream wrapped in a BinaryReader or BinaryWriter works well for this most use cases. However, those options don't allow you to swap out the underlying data source. If you want to read or write to a new buffer, you need to allocate new wrappers. They also make it difficult to reset the stream or read or write from pointers.
Most methods on this class rely on the native endianness of the system. A stream constructed on a big-endian system will not be compatible with one constructed on a little-endian system.
// auto-create the underlying data arrayvarstream=newReusableStream(initialCapacity);// use a pre-created underlying data arrayvarstream=newReusableStream(buffer,startIndex,readableLength);// create the stream without underlying data - MUST call ReplaceData before using streamvarstream=newReusableStream();stream.ReplaceData(buffer,startIndex,readableLength);// helper methods exist for reading and writing primitivesstream.Write(17);stream.ReadInt32();// reading into a managed bufferbyte[]bytes=newbyte[16];intbytesRead=stream.Read(bytes,0,bytes.Length);// reading into an unmanaged bufferbyte*bytes=stackallocbyte[16];intbytesRead=stream.Read(bytes,16);// writing from a bufferstream.Write(buffer,0,buffer.Length);stream.Write(bufferPtr,bufferLength);There are three forms of resetting.
- Replace the underlying data (by calling
ReplaceData()): resets everything about the stream including length and position. - Reset for reading (
ResetForReading()): resets the position of the stream back to the initial offset, but does not adjust the length. Useful if you want to re-read all data in the stream. Equivalent to callingSeek(0, SeekOrigin.Begin). - Reset for writing (
ResetForWriting()). Resets the position and length of the stream. Useful if you don't care about the underlying data anymore and want to write over it.
Strings can be written or read using any encoding. The default encoding is UTF8. You must specify (via the second parameter) whether the string should be considered nullable or not.
stream.DefaultEncoding=Encoding.UTF32;// change the default encodingvarnullableString=stream.ReadString(true);// read nullable stringvarnonNullableString=stream.ReadString(false);// read non-nullable stringstream.WriteString(nullableString,true);stream.WriteString(nonNullableString,false);// read/write a single string using a different encodingvarstr=stream.ReadString(true,Encoding.Unicode);stream.WriteString(str,true,Encoding.Unicode);When deserializing data, it is common to read the same strings over and over. In some cases, it may be preferable to reuse pre-allocated string objects rather than allocating a new string every time. For this, ReusableStream supports integrating with StringSet.
varset=newStringSet(initialCapacity);// add the strings you expect to seevartestString="test";set.Add(testString);set.Add("cat");set.Add("dog");
...// attach the set to the streamstream.StringSet=set;// create a new options structvaroptions=newStringSetOptions();/*For any string whose encoded size (in bytes) is less than or equal to this value, a lookup inStringSet will be performed before allocating a new string. If the string already exists inStringSet, then no allocation occurs. If it does not exist in StringSet, or if its encoded sizeis larger than this value, then a new string is allocated.For performance reasons, it is recommended to use a small value, such as 256, or less. Use zeroto disable StringSet lookups altogether.*/options.MaxEncodedSizeToLookupInSet=40;// make sure StringSet is non-null before calling thisstream.SetDefaultStringOptions(options);// write the test stringvaroriginalPosition=stream.Position;stream.WriteString(testString,true);// read it backstream.Seek(originalPosition,SeekOrigin.Begin);varreadString=stream.ReadString(true);// verify that we got the exact same string objectobject.ReferenceEquals(testString,readString);// trueYou can override the default StringSetOptions on each call to ReadString:
varoptions=newStringSetOptions();options.MaxEncodedSizeToLookupInSet=0;// disable StringSetvarnewString=stream.ReadString(true,setOptions:options);If your data is coming from a trusted source, you may elect to automatically add new strings to the StringSet. This is means every time you read a string whose encoded size is MaxEncodedSizeToLookupInSet or less, if it doesn't already exist in the StringSet, it will be added.
This is a bit dangerous because strings are never purged from StringSet. The strings will never be garbage collected unless the StringSet itself is garbage collected. A user could send you millions of unique strings, causing unbounded memory growth.
For this reason, the option is named PerformDangerousAutoAddToSet. Make sure you understand the consequences before enabling it.
varoptions=newStringSetOptions();options.MaxEncodedSizeToLookupInSet=40;options.PerformDangerousAutoAddToSet=true;StringSet is a specialized HashSet for strings. Its primary use case is as an intern pool for parsers because it allows you to extract a substring from char array without re-allocating if that substring has been seen before.
All methods on StringSet are thread-safe. Add* methods uses locking only when an existing match is not found. The get/search related methods were carefully designed to be thread-safe without requiring locking or spinning.
There are no "remove" methods. Once a string has been added, it remains in the set until the set itself is garbage collected.
varset=newStringSet(INITIAL_SIZE);You must provide an explicit initial size. The set will double in size every time the current size is exceeded.
If you already have the string allocated, you can add it directly:
set.Add(myString);If you have a char array buffer, you can add a string to the set by providing the buffer, an offset, and the length of the string. Additionally, there is an out string str parameter which is the resulting string. A new string will only be allocated if it does not already exist in the set.
stringresult;set.Add(myCharArray,start,length,outresult);All overloads of
Add()return aboolwhich is true if the string was added to the set, and false if the string already existed.
StringSet provides two different ways to check if a string already exists in the set (without adding it).
The first option is to pass in a char array:
varstr=set.GetExistingString(charArray,start,length);// if the string did not exist in the set, str will be nullThe second is to search for the string by hash code.
varhash=StringHash.GetHash(str);varcursor=set.GetSearchCursor(hash);while(cursor.MightHaveMore){if(str==cursor.NextString())returntrue;}returnfalseThe cursor will only iterate over strings that were present in the set at the time the cursor was created.
Note that
cursor.MightHaveMoreis exactly what it sounds like.NextString()might still return null even ifMightHaveMoreis true. OnceMightHaveMorebecomes false,NextString()will always return null.
Although the cursor does maintain a reference to a pre-existing array, the cursor itself is a struct, so performing searches does not incur any heap allocations.
StringHash is a supporting type for StringSet operations which represents the FNV-1a hash of a string.
The easiest way to generate the hash is by using one of the static GetHash() helper methods.
hash=StringHash.GetHash(myString);hash=StringHash.GetHash(myCharArray,start,length);You can also calculate the hash by manually iterating over the characters of your string.
varhash=StringHash.Begin();foreach(varchinmyString){hash.Iterate(ch);}// hash is now ready for useNever use
new StringHash()ordefault(StringHash)to create the hash. It will not be initialized properly. UseGetHash()orBegin()instead.
StringSet.GetSearchCursor() requires you to pre-calculate the hash of the string you're looking for. Several other methods of StringSet accept an optional StringHash parameter named knownHashValue. If you have pre-calculated the hash, you can use this parameter to save unnecessary re-calculation, but be sure you're supplying the correctly calculated hash. An incorrectly calculated hash could result in duplicate strings or make them unsearchable.
IMPORTANT: Don't ever add arbitrary user-provided strings to a StringSet. Remember that there is no "remove" functionality. A user could intentionally or unintentionally cause your memory usage to grow, and your application to eventually crash, by sending a large volume of unique strings.
StringSet is primarily intended to support parsers in a production environment where allocations matter (because of garbage collection performance). The most common use case would be to pre-allocate common strings you expect to see, and then use StringSet to avoid allocating a new string every time you parse a common value.
For example, let's say you want to parse a semicolon-delimited list of Stack Overflow tags (e.g. c#;java;c++;sql;). The naïve implementation would be:
vartags=tagListString.Split(new[]{';'},StringSplitOptions.RemoveEmptyEntries);There's nothing wrong with that implementation if allocations aren't a big deal for your application. On the other hand, if they are a big deal, here's how you could re-write this parser using StringSet:
// StringSet initialization code you would do oncevartagsWeCareExpect=new[]{"c#","java","c++"};varset=newStringSet(tagsWeCareExpect.Length+10);foreach(vartagintagsWeCareExpect){set.Add(tag);}// parsing codevarstart=0;for(vari=0;i<buffer.Length;i++){varch=buffer[i];if(ch==';'){varlen=i-start;varstr=set.GetExistingString(buffer,start,len);if(str==null){// Was not a tag we expected, so we have to allocate a new string.// We don't want to add it to the set because this is user input.str=newstring(buffer,start,len);}// todo - actually do something with this stringstart=i+1;}// todo - handle if the list isn't semi-colon terminated}Obviously that's a lot more code, but it may be worth it for high-volume parsers.
UnsafeStringComparer is a static helper class which helps you perform fast string equality comparisons when one operand is a string and the other is a character buffer. It is called "Unsafe..." because it uses unsafe code for optimizations and some methods accept pointers.
For strings with seven or fewer characters, they are compared one character at a time. For strings with eight or more characters, UnsafeStringComparer will switch to comparing four characters at a time via 64-bit integers, which can result in an almost 4x performance improvement.
SIMD would likely provide additional performance improvements, but C# does not appear to provide a way to use SIMD with unmanaged pointers. It would have to be done in a native dll, which would make this library less portable.
There is only one method with four public overloads:
boolAreEqual(stringstr,char[]buffer)Compares all characters from
strwith all characters inbuffer.
boolAreEqual(stringstr,char[]buffer,intstart,intlength)Compares all characters from
strwith the characters in buffer beginning at thestartindex, and forlengthcharacters. Iflength != str.Length, or ifstart + length > buffer.Length, the return value will always be false.
boolAreEqual(stringstr,char*buffer,intlength)Compares all the characters from
strwith a character buffer pointed to bybufferforlengthcharacters. Iflength != str.Length, the return value will always be false. It is up to you to ensure that the buffer has at leastlengthcharacters remaining, or this method may read from memory outside your buffer.
boolAreEqual(char*aPtr,char*bPtr,intlength)Compares characters from two buffers pointed at by
aPtrandbPtrforlengthcharacters. It is up to you to ensure both buffers have at leastlengthcharacters remaining, or this method may read from memory outside your buffers.
The MD5 methods in the BCL allocate a byte array in order to return the hash value. The methods on UnsafeMd5 return the hash as an Md5Digest struct, and therefore avoid any heap allocations. This implementation will out-perform the BCL implementation for small buffer sizes (~700 bytes or less), but is a slower on larger buffers.
Note: UnsafeMd5 will only return the accurate MD5 hash on little endian architectures (such as Intel x86).
Md5Digestdigest;// managed interfaceUnsafeMd5.ComputeHash(byteArray,outdigest);// unmanaged interfaceUnsafeMd5.ComputeHash(bytePtr,length,&digest);The Md5Digest struct is 16 bytes (the size of an MD5 hash). This constant is also available via Md5Digest.SIZE.
There are a few different ways to get the actual bytes from the digest struct.
// cast it as a byte pointervarbytesPtr=(byte*)&digest;// copy the bytes into a managed byte[] buffer starting at indexdigest.WriteBytes(buffer,index);// copy bytes to an unmanaged buffer (make sure at least 16 bytes are available)digest.WriteBytes(bufferPtr);// call GetBytes to simply allocate a new byte[]varbytes=digest.GetBytes();// call ToString to get a hex representation of the hash (obviously this allocates a string)varhex=digest.ToString();// example: 1f2cc2829f9ec439fab4f45ab54d8a82Md5Digest also implements IEquatable<Md5Digest> for comparison purposes and offers operator overloads for == and !=.
A small static utilities class for unsafe operations.
This is not a general-purpose replacement for memcpy or methods like Marshal.Copy. However, it is a fast alternative for small data buffers. In my testing, it will out-perform the alternatives for buffer sizes of around 400 bytes or less.
Unsafe.MemoryCopy(srcPtr,destPtr,bytesCount);Takes an unmanaged byte pointer and length (in bytes), and returns a hexadecimal representation of the data. The string itself is the only heap allocation this method makes. There are no intermediate objects.
varbytes=stackallocbyte[2];bytes[0]=0xa5;bytes[1]=0xf7;varstr=ToHexString(bytes,2);// "a5f7"