Search code examples
dictionaryobjecthaxe

Haxe Maps vs Dynamic Object vs Fixed Object performance CPP


It seems maps in haxe are extremely slow vs dynamic objects

I would avoid them.

So using this code:

        var nd=()->{
        
         //var op:Dynamic  = {x:100,y:1000};
         //op.z = 22;

         var op = {x:100,y:1000,z:22}

        //var op = ['x'=>100,'y'=>1000];
        //op['z'] = 22;

        var i;
        for(i in 0...1000000)
        {
            /*
           op['x']++;
           op['y']--;
           op['z']++;
           */
           op.x++;
           op.y--;
           op.z++;
        }

        trace('Line');
        }

        var j;
        var q:Float = haxe.Timer.stamp();
        for(j in 0...100) nd();
        trace(haxe.Timer.stamp()-q);

  • Maps: 19 second
  • Dynamic Object: 9 seconds
  • Object: 0.6 seconds

It's amazing how slow maps are


Solution

  • It's not that maps are slow, it's that your test doesn't consider compiler optimizations. And it seems like it was ran in Debug mode?

    Let's take a look at a slightly more verbose test (10x fewer iterations, shuffled order and averages):

    import haxe.DynamicAccess;
    
    class Main {
        static inline var times = 10000;
        static function testInline() {
            var o = { x: 100, y: 1000, z: 22 };
            for (_ in 0 ... times) {
                o.x++;
                o.y--;
                o.z++;
            }
        }
        
        static function getClass() {
            return new Vector(100, 1000, 22);
        }
        static function testClass() {
            var o = getClass();
            for (_ in 0 ... times) {
                o.x++;
                o.y--;
                o.z++;
            }
        }
        
        static function testClassDynamic() {
            var o:Dynamic = getClass();
            for (_ in 0 ... times) {
                o.x++;
                o.y--;
                o.z++;
            }
        }
        
        static function getObj() {
            return { x: 100, y: 1000, z: 22 };
        }
        static function testObj() {
            var o = getObj();
            for (_ in 0 ... times) {
                o.x++;
                o.y--;
                o.z++;
            }
        }
        static function testDynamic() {
            var o:Dynamic = { x: 100, y: 1000, z: 22 };
            for (_ in 0 ... times) {
                o.x++;
                o.y--;
                o.z++;
            }
        }
        
        static function testDynamicPlus() {
            var o:Dynamic = { };
            o.x = 100;
            o.y = 1000;
            o.z = 22;
            for (_ in 0 ... times) {
                o.x++;
                o.y--;
                o.z++;
            }
        }
        static function testDynamicAccess() {
            var o:DynamicAccess<Int> = getObj();
            for (_ in 0 ... times) {
                o["x"]++;
                o["y"]--;
                o["z"]++;
            }
        }
        static function testMapString() {
            var o = ["x" => 100, "y" => 1000, "z" => 22];
            for (_ in 0 ... times) {
                o["x"]++;
                o["y"]--;
                o["z"]++;
            }
        }
        static function testMapInt() {
            var o = [100 => 100, 200 => 1000, 300 => 22];
            for (_ in 0 ... times) {
                o[100]++;
                o[200]--;
                o[300]++;
            }
        }
        static function shuffleSorter(a, b) {
            return Math.random() > 0.5 ? 1 : -1;
        }
        static function main() {
            var tests = [
                new Test("inline", testInline),
                new Test("class", testClass),
                new Test("object", testObj),
                new Test("object:Dynamic", testDynamic),
                new Test("class:Dynamic", testClassDynamic),
                new Test("object:Dynamic+", testDynamicPlus),
                new Test("DynamicAccess", testDynamicAccess),
                new Test("Map<String, Int>", testMapString),
                new Test("Map<Int, Int>", testMapInt),
            ];
            
            var shuffle = tests.copy();
            var iterations = 0;
            
            while (true) {
                iterations += 1;
                Sys.println("Step " + iterations);
                
                for (i => v in shuffle) {
                    var k = Std.random(shuffle.length);
                    shuffle[i] = shuffle[k];
                    shuffle[k] = v;
                }
                
                for (test in shuffle) {
                    var t0 = haxe.Timer.stamp();
                    var fn = test.func;
                    for (_ in 0 ... 100) fn();
                    var t1 = haxe.Timer.stamp();
                    test.time += t1 - t0;
                    Sys.sleep(0.001);
                }
                
                for (test in tests) {
                    Sys.println('${test.name}: ${Math.ffloor(test.time / iterations * 10e6) / 1e3}ms avg');
                }
                
                Sys.sleep(1);
            }
        }
        
    }
    
    class Test {
        public var time:Float = 0;
        public var func:Void->Void;
        public var name:String;
        public function new(name:String, func:Void->Void) {
            this.name = name;
            this.func = func;
        }
        public function toString() return 'Test($name)';
    }
    
    class Vector {
        public var x:Int;
        public var y:Int;
        public var z:Int;
        public function new(x:Int, y:Int, z:Int) {
            this.x = x;
            this.y = y;
            this.z = z;
        }
    }
    
    

    And its output after a hundred or so "steps":

    inline: 0.011ms avg
    class: 15.737ms avg
    object: 281.417ms avg
    object:Dynamic: 275.509ms avg
    class:Dynamic: 233.208ms avg
    object:Dynamic+: 1208.83ms avg
    DynamicAccess: 1021.248ms avg
    Map<String, Int>: 1293.529ms avg
    Map<Int, Int>: 916.552ms avg
    

    Let's take a look at what each test compiles to.
    Haxe-generated C++ code is formatted for readability

    inline

    This is what you were testing, although you have evidently suspected something judging by the commented out line.

    If it seems suspiciously fast, that's because it is - the Haxe compiler noticed that your object is local and inlined it completely:

    void Main_obj::testInline()
    {
        HX_STACKFRAME(&_hx_pos_e47a9afac0942eb9_5_testInline)
        int o_x = 100;
        int o_y = 1000;
        int o_z = 22;
        {
            int _g = 0;
            while ((_g < 10000))
            {
                _g = (_g + 1);
                int _ = (_g - 1);
                o_x = (o_x + 1);
                o_y = (o_y - 1);
                o_z = (o_z + 1);
            }
        }
    }
    

    Consequently, the C++ compiler might figure out that you aren't really doing anything in this function, at which point the contents are removed:

    enter image description here

    (whereas if you were to return o.z, the contents would be equivalent to return 10022 instead)

    class

    Let's talk about the things that you should be doing in a good case scenario.

    Known field access on a class instance is very fast because it is compiled to a C++ class with direct field access:

    ::Vector Main_obj::getClass()
    {
        HX_GC_STACKFRAME(&_hx_pos_e47a9afac0942eb9_15_getClass)
        return ::Vector_obj::__alloc(HX_CTX, 100, 1000, 22);
    }
    
    void Main_obj::testClass()
    {
        HX_STACKFRAME(&_hx_pos_e47a9afac0942eb9_17_testClass)
        ::Vector o = ::Main_obj::getClass();
        {
            int _g = 0;
            while ((_g < 10000))
            {
                _g = (_g + 1);
                int _ = (_g - 1);
                o->x++;
                o->y--;
                o->z++;
            }
        }
    }
    

    Getting a class from a function call is required to prevent the Haxe compiler from inlining it; the C++ compiler may still collapse the for-loop.

    object

    Let's prevent the compiler from inlining the anonymous object by returning it from a function.

    But it's still faster than a map.

    Since dynamic objects are used pretty often (JSON and all), a few tricks are utilized - for instance, if you are creating an anonymous object with a set of predefined fields, additional work will be performed for these so that they can be accessed quicker (seen here as Create(n) and a subsequent chain of setFixed calls):

    ::Dynamic Main_obj::getObj()
    {
        HX_STACKFRAME(&_hx_pos_e47a9afac0942eb9_36_getObj)
        return ::Dynamic(::hx::Anon_obj::Create(3)
                             ->setFixed(0, HX_("x", 78, 00, 00, 00), 100)
                             ->setFixed(1, HX_("y", 79, 00, 00, 00), 1000)
                             ->setFixed(2, HX_("z", 7a, 00, 00, 00), 22));
    }
    
    void Main_obj::testObj()
    {
        HX_STACKFRAME(&_hx_pos_e47a9afac0942eb9_38_testObj)
        ::Dynamic o = ::Main_obj::getObj();
        {
            int _g = 0;
            while ((_g < 10000))
            {
                _g = (_g + 1);
                int _ = (_g - 1);
                ::hx::FieldRef((o).mPtr, HX_("x", 78, 00, 00, 00))++;
                ::hx::FieldRef((o).mPtr, HX_("y", 79, 00, 00, 00))--;
                ::hx::FieldRef((o).mPtr, HX_("z", 7a, 00, 00, 00))++;
            }
        }
    }
    

    You can see a handful of these tricks in Anon.cpp and Anon.h.

    Dynamic

    Same as above, but by typing the variable as Dynamic instead, and without an extra function call. Personally I wouldn't rely on this behaviour.

    class:Dynamic

    Although the code is effectively identical to above,

    void Main_obj::testClassDynamic()
    {
        HX_STACKFRAME(&_hx_pos_e47a9afac0942eb9_26_testClassDynamic)
        ::Dynamic o = ::Main_obj::getClass();
        {
            int _g = 0;
            while ((_g < 10000))
            {
                _g = (_g + 1);
                int _ = (_g - 1);
                ::hx::FieldRef((o).mPtr, HX_("x", 78, 00, 00, 00))++;
                ::hx::FieldRef((o).mPtr, HX_("y", 79, 00, 00, 00))--;
                ::hx::FieldRef((o).mPtr, HX_("z", 7a, 00, 00, 00))++;
            }
        }
    }
    

    this runs a little faster. This is a accomplished by pre-generating functions for Reflection that will first check if the variable happens to be one of the predefined ones:

    ::hx::Val Vector_obj::__Field(const ::String &inName,::hx::PropertyAccess inCallProp)
    {
        switch(inName.length) {
        case 1:
            if (HX_FIELD_EQ(inName,"x") ) { return ::hx::Val( x ); }
            if (HX_FIELD_EQ(inName,"y") ) { return ::hx::Val( y ); }
            if (HX_FIELD_EQ(inName,"z") ) { return ::hx::Val( z ); }
        }
        return super::__Field(inName,inCallProp);
    }
    

    DynamicAccess

    Same as Dynamic, but we're also forcing the runtime to jump through a few (unnecessary) hoops with Reflect functions.

    void Main_obj::testDynamicAccess()
    {
        HX_STACKFRAME(&_hx_pos_e47a9afac0942eb9_66_testDynamicAccess)
        ::Dynamic o = ::Main_obj::getObj();
        {
            int _g = 0;
            while ((_g < 10000))
            {
                _g = (_g + 1);
                int _ = (_g - 1);
                {
                    ::String tmp = HX_("x", 78, 00, 00, 00);
                    {
                        int value = ((int)((::Reflect_obj::field(o, tmp) + 1)));
                        ::Reflect_obj::setField(o, tmp, value);
                    }
                }
                // ...
            }
        }
    }
    

    object:Dynamic+

    We can disregard the aforementioned predefined field optimization by creating an empty Dynamic object and then filling it up with fields. This puts us pretty close to Map's performance.

    void Main_obj::testDynamicPlus()
    {
        HX_STACKFRAME(&_hx_pos_e47a9afac0942eb9_55_testDynamicPlus)
        ::Dynamic o = ::Dynamic(::hx::Anon_obj::Create(0));
        o->__SetField(HX_("x", 78, 00, 00, 00), 100, ::hx::paccDynamic);
        o->__SetField(HX_("y", 79, 00, 00, 00), 1000, ::hx::paccDynamic);
        o->__SetField(HX_("z", 7a, 00, 00, 00), 22, ::hx::paccDynamic);
        {
            int _g = 0;
            while ((_g < 10000))
            {
                _g = (_g + 1);
                int _ = (_g - 1);
                ::hx::FieldRef((o).mPtr, HX_("x", 78, 00, 00, 00))++;
                ::hx::FieldRef((o).mPtr, HX_("y", 79, 00, 00, 00))--;
                ::hx::FieldRef((o).mPtr, HX_("z", 7a, 00, 00, 00))++;
            }
        }
    }
    

    Map<String, Int>

    Given that Map is unable to benefit from most of the above contextual optimizations (in fact, many wouldn't make sense for normal use cases), its performance should not be particularly surprising.

    void Main_obj::testMapString()
    {
        HX_GC_STACKFRAME(&_hx_pos_e47a9afac0942eb9_74_testMapString)
        ::haxe::ds::StringMap _g = ::haxe::ds::StringMap_obj::__alloc(HX_CTX);
        _g->set(HX_("x", 78, 00, 00, 00), 100);
        _g->set(HX_("y", 79, 00, 00, 00), 1000);
        _g->set(HX_("z", 7a, 00, 00, 00), 22);
        ::haxe::ds::StringMap o = _g;
        {
            int _g1 = 0;
            while ((_g1 < 10000))
            {
                _g1 = (_g1 + 1);
                int _ = (_g1 - 1);
                {
                    ::String tmp = HX_("x", 78, 00, 00, 00);
                    {
                        int v = ((int)((o->get(tmp) + 1)));
                        o->set(tmp, v);
                    }
                }
                // ...
            }
        }
    }
    

    Map<String, Int>

    A bonus: to any or no surprise, computing a hash of an integer is cheaper than doing so for a string.

    Conclusions

    Don't be hasteful to write your code one or other way based on what a microbenchmark suggests. For example, although this might seem like an in-depth breakdown, it does not account for garbage collection nor optimization differences between various C++ compilers.