TValue and other “Variants” like implementation tests – revised
Recently I got an inquiry about the speed of my TAnyValue implementation. For the record TAnyValue is a TValue like implementation using records and implicit operators. It lacks in features compared to TValue but it is faster and it works on older Delphi versions (Delphi 2005 and up). I also like to tinker with things like this, so it was fun to play around with it. I was inspired by the TOmniValue which is part of OmniThreadLibrary. My goal was to eventually make it faster, to make it the fastest “variant” type implementation out there.
In the 2010 I did the initial tests with my initial version of the TAnyValue. You can find the results here. Back then TAnyValue was somehow on par with TOmniValue. It was quite slower then Variants and a simple variable record (which is the fastest it can be and is a good comparison on how fast you really are). TValue was catastrophic back then being by far the slowest solution because of generics which it used internally. I then improved my implementation over time being silent about it. But I came a long way and today my solution is the fastest out there. But It must be said that all solutions today are fast, the differences only matters if you use really a lot of these values (assignments) per second and you absolutely need the speed.
When doing such an implementation, the tricky part is to get a good balance between memory consumption and speed. The simplest and most crude solution would be to store every possiible data type you want to handle in the record as internal variable (field). This way you don’t have to explicitly assign memory or copy bytes. The compiler does it all and this is the fastest possible way. But it is also terrible in regards to memory consumption. I will make the case on my TAnyValue and the types it supports at this moment. If I had the simplest and fastest solution I would cover these types (I assume 32 bit compiler here):
- Int64 (64)
- Integer (32)
- Extended (80)
- WideString (variable)
- AnsiString (variable)
- String (variable)
- Boolean (8)
- TObject (32)
- Pointer (32)
- Cardinal (32)
- TDateTime (80)
- IInterface (32)
Ok this is a really dumb aproach but I want an upper memory consumption limit. Application with 10000000 such records, holding one integer each, consumed a whooping 706.244 KB of memory. A lot! On the other side of the spectrum you have two different solution. You can use variants for inner data storage but I really wanted to avoid them because what would be the point using them
Another very sleek approach is what TOmniValue has done. It uses one Int64 field for most of the data types with very smart assignments and one IInterface field for Extended and strings types. Basically for all types that need finalization as you cannot have a destructor in a record. The approach TOmniValue uses is good but if you work with strings and floating points a lot, it will be slow as interfaces are notoriously slow. I wanted something fast and still not to hard on memory consumption. So I came to this:
TSimpleData = record case Byte of atInteger: (VInteger: Integer); atCardinal: (VCardinal: Cardinal); atBoolean: (VBoolean: Boolean); atObject: (VObject: TObject); atPointer: (VPointer: Pointer); atClass: (VClass: TClass); atWideChar: (VWideChar: WideChar); atChar: (VChar: AnsiChar); {$IFDEF AnyValue_UseLargeNumbers} atInt64: (VInt64: Int64); atExtended: (VExtended: Extended); {$ENDIF} end; TAnyValue = packed record private FValueType: TValueType; {$IFNDEF AnyValue_NoInterfaces} FInterface: IInterface; {$ENDIF} FSimpleData: TSimpleData; FComplexData: array of Byte; ... end; |
I use three fields. I use IInterface only for interfaces, thus the conditional define, so you can easily turn them off if you don’t need them and so save memory. Also the trick here is in the variable record. The good side of this record is, it only takes the amount of memory that the largest field does. In my case this is 32 bit, unless “AnyValue_UseLargeNumbers” is defined, then this is 80 bit. This way I cut down on size dramatically, by 48 bit per record. Finally ,there is a dynamic array of bytes, for strings and floating point values if “AnyValue_UseLargeNumbers” is not defined. So lets look that the memory consumption compared to others (again holding 32 bit integers and 10000000 records):
TAnyValue (no defines): 128.960 KB
TAnyValue (AnyValue_NoInterfaces): 89.812 KB
TAnyValue (AnyValue_UseLargeNumbers): 246.376 KB
TAnyValue (AnyValue_NoInterfaces and AnyValue_UseLargeNumbers): 207.228 KB
TOmniValue: 128.960 KB
TValue: 246.376 KB (wow not only is TValue slow but takes a lot of memory)
Variants: 158.308 KB
The only problem when not using “AnyValue_UseLargeNumbers” is that if you then actually use floating point types, you will consume more memory then without that directive. And you will be a little bit slower. You can still use floating points but they should not be in majority. So you can tweak TAnyValue to step up to the task at hand. I should also add that numbers would naturally be different if other data type would be used. But for overall picture 32 bit Integer is just fine.
Now lets look at speed results. The test application is the same as it was last time.
| Type | Variants | TValue | TAnyValue | TOmniValue | TVariableRec |
| j := I | 178 | 3431 | 62 | 108 | 68 |
| j := I/5 | 187 | 3968 | 230 | 4054 | 199 |
| j := IntToStr(I) | 5491 | 10593 | 4342 | 6632 | 2728 |
| ALL | 4479 | 19541 | 4158 | 10966 | 3140 |
As you can see TAnyValue gained a lot of speed, everything else is the same as it was back then. I also did the full test on XE3 to see if TValue improved in any way. The results are bellow
| Type | Variants | TValue | TAnyValue | TOmniValue | TVariableRec |
| ALL | 3846 | 6497 | 3318 | 10613 | 2062 |
It is clear that they worked on TValue which is now fast enough for use. In fact it is very fast. Given together with flexibility it is a powerful tool.
Probably a lot of you wonder why even bother. I bother because I can. I like to tweak the code and see if I can make it even better. So if any of you have ideas how to make it even smaller in regards to memory consumption and retain the speed throne, please let me know
P.S.
If you are looking for TAnyValue you can find it as a part of Cromis Library in the downloads section.
Cesar Romero wrote,
Would be nice to have the benchmarks with string operations too.
Link | February 4th, 2013 at 11:45 pm
Stefan Glienke wrote,
Of course TValue is slower than TAnyValue. It can hold *any* type – not just simple value types and reference types – and it handles lifetime of complex value types correctly.
Also TValue is not a replacement for Variant because it’s type conversion is very strict.
TAnyValue has no clear way of handling type conversion – example:
TAnyValue(1).AsBoolean returns true (because it just accesses the vBoolean field regardless the type that was put in initially – which may lead to corrupted data) but TAnyValue(1).Equal(Int64(1)) returns false (because the type is checked first which is even stricter than TValue).
Link | February 5th, 2013 at 11:23 am
Iztok Kacin wrote,
@Stefan
Sure TValue has more powerfull interface and features, I pointed that out in the article. But:
1. I cannot use it in older versions of delphi and that is a must for TAnyValue.
2. TValue in Delphi 2010 is so slow it is unusable. It is ok in XE3, I have not checked the versions in between.
These two points is why TValue is a no-go for me. Now to address the other points you made.
1. I agree TAnyValue can hold any time. But what TAnyValue cannot hold (arrays as for now, but that will change, and array is not a type)?
2. I don’t want lifetime to be handled. Why would I. TAnyValue is a wrapper, a carrier and for it to interude on lifetime management would be plain wrong in my opinion. The one that created an object should also free it. At least I see it that way.
3. I agree on type coversion. I just did not implement it yet. But it can be done easily. I know what type of data it holds, its just a matter of checking it. The idea was to allow as seamless automatic conversion as possible. TAnyValue(1).AsString is what I just expect to work. I will implement checks and then this will also work.
I will also probably implement some sort of generics support for newer versions of Delphi. But lets be fair I use TAnyValue a lot and it is mainly used to carry numbers and strings and in some instances objects, but that is rare. When I implement all I pointed out, TAnyValue will still be faster, have half the memory consumption of TValue and very little loss in terms of usage powers.
But I will be glad if you have some usage examples that you would like to see TAnyValue support or you know TValue supports and TAnyValue does not.
Link | February 5th, 2013 at 12:36 pm
Stefan Glienke wrote,
I am not using Delphi 2010 (only to compile and run tests against the libraries I work on to make sure they pass) but XE so I can not say anything about the performance on 2010 – I think the change was from 2010 to XE, from then on afaik TValue was not changed much.
Keep in mind what TValue is for: holding *any* value of *any* type defined. Handling lifetime is crucial for that. I am not talking about changing the responsability of lifetime management but to keep things alife that are needed. For instance if you store a record with some managed type in a TValue (I have not seen any way to do that with TAnyValue) and then free the original owner the record “instance” its managed content is still valid because it used the correct routines to copy the data (see Rtti.TValueDataImpl.Create).
I suspect this is the cause of the performance difference between TAnyValue and TValue because TValue does unneeded overhead even for simple types (like the Implicit operator for Integer calls From instead of just putting the passed Value into the FData.FAsSLong field – at least in XE).
Link | February 5th, 2013 at 1:10 pm
Iztok Kacin wrote,
Yes, TAnyValue is more like a Virant, its main purpose is to hold any data or reference and make it easy for programmers. I use it in hash tables, queues etc…, mainly to hold or transfer data. That is different as TValue. I don’t want to hold the value of referenced types, objects etc…and make a copy of it. This would only make confusion in my way of using it.
In Delphi 2010 TValue was 4x slower then it is now. They fixed that in XE obviously.
Link | February 5th, 2013 at 1:37 pm
Stefan Glienke wrote,
I took another look at TValue and it seems with XE2 it got another performance improvement for the implicit operators and the AsXXX methods. These were the bottlenecks in your tests as they were using the From and AsType methods before.
I think I could write a Rtti.TValue assignment compatible wrapper around TValueData to apply these improvements to earlier versions and bring the performance on par with TAnyValue.
Link | February 5th, 2013 at 3:37 pm
Iztok Kacin wrote,
It would probably be a good idea for people that still use 2010 or XE. If you have the time and will to do it, then why not.
TAnyValue will still be a little bit faster (the difference is nonexistant in real apps anyway) and will have smaller memory footprint but TValue is a powerhouse if used correctly.
To be honest I mainly wrote TAnyValue, cause I like to play around with things like this and I like the chalange. I could just use variants and it would be perfectly fine.
Link | February 5th, 2013 at 4:54 pm
ObjectMethodology.com wrote,
Down low, but great stuff, thx.
Link | February 5th, 2013 at 6:02 pm
Iztok Kacin wrote,
@ObjectMethodology
Thank. Glad you liked it. I have to blog more again
@Cesar
Sorry for late reply. You probably meant the test under XE3 to be complete? If so I can run the whole benchmark.
Link | February 5th, 2013 at 7:25 pm
Stefan Glienke wrote,
Actually I found TValue to be faster in XE3 when testing for only Integer and Extended (and so is my wrapper that uses the optimized Implicit and AsInteger/AsExtended methods). It also does not use more memory than TAnyValue does in these cases (without the special compiler directives that is).
It is slower for strings because in every loop iteration it destroys and creates the TValueDataImpl instance when putting a new value while TAnyValue uses the FComplexData Byte array. But I haven’t found a way around that.
Link | February 6th, 2013 at 8:58 am
Iztok Kacin wrote,
@Stefan
Great work. Thanks for helping. Yesterday I uploaded a new version of TAnyValue. I inverted one define. So now out of the box extended and int64 values are stored into the byte array. This way I conserve 48 bits per record if I remember correctly (if no extended and int64 values are used ofc). This is the most optimal setup I think and should be the default in most cases.
I will repeat the test under XE3 for Integer and Extended. The speed should be the same there as both TValue and TAnyValue basically use the same principle at storing simple types.
Thanks again for the great debate
Link | February 6th, 2013 at 11:14 am
Stefan Glienke wrote,
Now you traded memory use for speed
Only comparing Extended of my TValueData wrapper (similar to TValue in XE3):
TValue: 300
TAnyValue: 1570
Changed some code and went down to 280 for TAnyValue
In GetAsFloat:
Result := PExtended(@FComplexData[0])^;
In SetAsFloat:
if Length(FComplexData) SizeOf(Extended) then
SetLength(FComplexData, SizeOf(Extended));
PExtended(@FComplexData[0])^ := Value;
And removed the inline from the Implicit(const Value: Extended): TAnyValue; which got me down the last 200ms!
Nice example on how inline actually can decrease performance instead of improve – I actually was surprised myself. On XE3 the impact is not as high as in XE though.
Link | February 6th, 2013 at 3:06 pm
Iztok Kacin wrote,
Uh, nice solution there with PExtended. I did not thought of that
Yea I know I traded speed, but I did not actually have the time to test it and did not expect move to be so slow for a simple Extended value. Well now with your solution I can rerun the tests. I will do the same for Int64, which then leaves only string to use the Move.
If all of it comes out right this will then be one very good solution. Thanks to you.
Link | February 6th, 2013 at 4:06 pm
Iztok Kacin wrote,
That is very interesting. Tried some more tests and it seems that inline only makes matters worse on XE3 when using FComplexData. It works ok on 2010. Furthermore inline actually helps speed things up if using simple data types.
Additionally my extra array variable contributes to added initialization time as records needs more time to be reserved on the stack as it needs in case of TValue. TValue does not have that array there.
Link | February 6th, 2013 at 4:56 pm
Iztok Kacin wrote,
@Stefan
How did you get 200ms with TAnyValue for extended? I don’t even come close to it. I have been testing a lot, funny things these compilers
Link | February 6th, 2013 at 8:05 pm
Stefan Glienke wrote,
Got it down to 280ms, 200ms was the gain for removing the inline on the Implicit operator for Extended. Before I had it on 480ms.
Link | February 7th, 2013 at 8:19 am
Iztok Kacin wrote,
Can you please send me the unit to my mail. I want to see the times on my machine and compare it to my final version.
Mail is “iztok dot kacin at gmail dot com”
Thanks
Link | February 7th, 2013 at 9:05 am
From Zero To One » Blog Archive » TValue and other “Variants” like implementation tests – finale wrote,
[...] truly fast now and uses little memory out of the box. The comments and previous post can be found here. What we did [...]
Link | February 7th, 2013 at 1:25 pm
From Zero To One » Blog Archive » TAnyValue, an attempt to make the best “variable” data container wrote,
[...] TValue and other “Variants” like implementation tests – revised [...]
Link | February 14th, 2013 at 8:39 am