Uploaded image for project: 'Qt'
  1. Qt
  2. QTBUG-82447

[Qt6]Batched Evaluation of QML Bindings using expression normalization and implicit sharing of properties

    XMLWordPrintable

Details

    Description

      The ease of using bindings is the strongest selling point of QML. However in practice, quite so often, the evaluation of bindings leads to app freezes and missed frames. It spills intermediate values and clogs the UI thread with tens of thousands of useless update calls at a time.

      But things don't have to be this way. Binding evaluation can be made to run just as fast as manual assignment, if not faster for some use cases. This would hold true for both QML and C++.

      I am using QML since 2011. I have spent more than 15000 hours developing QML apps. Here's what I propose to make QML bindings better than ever:

      1. Methods to turn off and turn on the binding evaluation engine so that value changes can be batched.
      2. Q_SHAREABLE_PROPERTY that can be implicitly shared by multiple items.
      3. Evaluation of QML bindings begins at compile time. It can be broken down into 2 parts:
        • normalization(reduction) of binding expressions into a simplified normal form expressions(NFE) at compile time. Binding expressions with the same NFE will implicitly share the same property.
        • evaluation of the NFEs at runtime; Binding expressions with the same NFE are evaluated only once, all that thanks to implicit sharing.
      4. Qt.sealedBinding(..) Sealed bindings are pass-by-expression constructs. A sealed binding cannot be broken. The seal operation cannot be undone. The value of the property will be exclusively modified by the Binding evaluation engine.
      5. Qt.unsafeBinding(..) Unsafe bindings are pass-by-value constructs. Prevents feeding of overly complex binding expressions to the binding expression parser.
        • Unsafe bindings should be kept as functions and evaluated at runtime. Current QML2 bindings would be synonymous to unsafe QML3 bindings.
        • Value-change signals should be superseded by event functions called by the binding engine.
      6. Cache at runtime commonly computed values specified by the developer. Keep them even if the ref count goes nil. The more it runs, the faster it runs.
      7. Binding safety: You should seal simple bindings if you don't plan to manually change them. You should mark a property unsafe if the expression is overly complex or you change it very often. Use these 2 keywords to fine-grain between CPU use and memory use.

      Now lets take a deep dive into the details.

      1) Methods to turn off and turn on the binding evaluation engine, so that value changes can be batched.

      • The need for this has been discussed here: QTBUG-71577 - QML needs a way to atomically update multiple bindings at the same time.
      Possible Syntax (1/2) => Qt.safeBind() , Qt.safeBindEnd()
      	
      Qt.safeBind();
      ... random function calls and assignments of values to random items in a random order
      Qt.safeBindEnd();
      
      Possible Syntax (2/2) => Qt.safeBindAssign(..), Qt.safeBindCall(..)
      	
      Rectangle{ id: rect_1; width: 50; height: 50; color: "green"; }
      Rectangle{ id: rect_2; width: 50; height: 50; color: "green"; }
      
      property var func: (t) => { t.width*=2; t.height*=2; t.x+=20; t.y+=5; }
      
      TapHandler{
       onTapped: {
        Qt.safeBindAssign(rect_1,{"x":200,"y":200,"width":100,"height":100,"color":"cornflowerblue"}, rect_2,{"x":120,"y":250});
        Qt.safeBindCall( () => {rect_1.x=200; rect_1.y=200; rect_2.x+=5; rect_2.x-=5; rect_1.height+=100; rect_1.color="cornflowerblue";} );
        Qt.safeBindCall( func , rect_1 );
        Qt.safeBindCall( (r1,r2) => { r1.width*=2; r2.height*=2; r1.x+=20; r2.y+=5; } , rect_1,rect_2 );
       }
      }
      

      Let's introduce 2 key concepts:

      • Graph of Binding Expressions (GBE) from which through expression normalization we build the GNFE
      • Graph of Normal Form Expressions (GNFE) used for computing the final values. This is built to avoid redundant data and redundant computations.

       

      • After a Qt.safeBind() call, the modified properties will be made dirty. After we call Qt.safeBindEnd() we check the dirty properties for false positives. Eg. a++ followed by a-- is a false positive. We make it clean again and move to the next step.
      • We check for broken bindings and for newly assigned bindings. If found, we need to update both the GBE and the GNFE. Finally we can evaluate the values in the GNFE, one step at a time, breadth first using all head value nodes as starting points

      2) Q_SHAREABLE_PROPERTY that can be implicitly shared by multiple items.

      Window {
       ...
       Item{ id:a; width: parent.width }
       Item{ id:b; width: parent.width }
       Item{ id:c; width: parent.width }
      }
      
      • What happens when we have multiple items that share the same binding? Do we need to keep the data in multiple places? Do we need to evaluate the same expression multiple times? How about we instantiate a single property, evaluate it once and pass the pointer to the items?
      • A Q_SHAREABLE_PROPERTY can be managed by the global binding engine.

      3) Evaluation of QML bindings begins at compile time.

      Window {
       id: root
       .....
       Item{ id: a1; x: root.x+75;
        Item{id: a2; x:  a1.x+125; }
       }
       
       Item{  id: b1; x: root.x+20;
        Item{ id: b2; x:  b1.x+180; }
       }
      
       Item{ id: c1; x: root.x+50;
        Item{ id: c2; x:  c1.x-root.x; }
       }
      }
      

      Graph of Binding Expressions (GBE)

        root.x  
      { a1.x: root.x+75 } { b1.x: root.x+20 } { c1.x: root.x+50 }
      { a2.x: a1.x+125 } { b2.x: b1.x+180 } { c2.x: c1.x-root.x }

      By employing different reduction strategies you should be able to obtain a GNFE where every expression is based on head values.

      Graph of Normal Form Expressions (GNFE) , where root.x = HV ( Head Value )

          root.x = HV  
      { a1.x: root.x+75 } { b1.x: root.x+20 } { c1.x: root.x+50 } { a2.x,b2.x: root.x+200 }
      • Although c2.x is binded, it always evaluates to c2.x = c1.x-root.x = (root.x+50 - root.x)= 50. It has a predefined value, so it doesn't need to be part of the GNFE. The GNFE together with the values retained in its nodes doesn't have to be isomorphic to GBE.
      • For each node in the GNFE we create a property. Then the property's pointer is passed to the Q_SHAREABLE_PROPERTY of the items found in the node.
      • E.g. a2.x and b2.x share the same NFE, thus it is only right for them to share the same property via pointer.

      4) Sealed bindings

      Possible Syntax => sealed , Qt.sealedBinding(..)
      	
      property int a: 23
      sealed property int b: a  // Acts as a hintword to the QML compiler/parser
      sealed property int c: 20 // No binding found; Defaults to readonly 
      property int d: 0
      
      Component.onCompleted: {
       b = 50;  // TypeError: Cannot assign to sealed property "b"
       b = Qt.binding( () => a+25); // TypeError: Cannot assign to sealed property "b"
       console.log("Value of b: ",b); // Value of b: 23
       a+=12;
       console.log("Value of b: ",b); // Value of b: 35
      
       d = Qt.sealedBinding( () => a+20 );
       d = 50;  // TypeError: Cannot assign to sealed property "d"
       console.log("Value of d: ",d); // Value of d: 55
      }
      	
      Item{
       sealed width: parent.a // Seal a predefined property
      }
      
      • A sealed binding cannot be broken. The seal operation cannot be undone. The value of the property will be exclusively modified by the Binding evaluation engine. Some developers might like this keyword simply because they can guard themselves against broken bindings. But this keyword can play a more far-reaching role. Sealed bindings are pass-by-expression constructs.
      • Multi depth chained sealed paths in GBEs can be collapsed in same depth vertices of GNFEs. Even if the user hasn't provided the sealed keyword, at compile time the parser should do its best to determine what is sealed and what it's not.
      • Furthermore for components that are not yet part of the main app the parser can prepare at compile-time the static sub-GBEs and static sub-GNFEs. They are computed once and when the components are dynamically allocated at run-time the static sub-GBE-s and sub-GNFEs can easily be attached to the app's GBE and the head value nodes of the app's GNFE.

      5) Unsafe property and Qt.unsafeBinding(..). Unsafe bindings are pass-by-value constructs.

      • Prevents feeding of overly algorithmically complex binding expressions to the binding expression parser. Unsafe bindings should be kept as functions and evaluated at runtime. Current QML2 bindings would be synonymous to unsafe QML3 bindings. Value-change signals can be superseded by event functions called by the binding engine.
      Window {
       id: root
       ...
       property var fib: (n)=> (n<=1)?1:fib(n-1)+fib(n-2)
      
       Item{ id: a1; x: root.x+75;
        Item{id: a2; x:  a1.x+125; }
       }
      
       Item{
        id: b1;
        unsafe x: fib(root.x+20);
        Item{ id: b2; x:  b1.x+180; }
       }
      }
      

      Graph of Binding Expressions (GBE)

        root.x
      { a1.x: root.x+75 } { b1.x: fib(root.x+20) }
      { a2.x: a1.x+125 } { b2.x: b1.x+180 }

      Graph of Normal Form Expressions (GNFE)

      Step 0    root.x = HV  
      Step 1 { a1.x: root.x+75 } { b1.x: fib(root.x+20) }

      = HV Node

      { a2.x: root.x+200 }
      Step 2 . { b2.x: b1.x+180 } .
      • An unsafe binding will add extra depth to the GNFE. You will first need to compute values in Step 1. Then check for broken bindings and for newly assigned bindings. If found, we need to update both the GBE and the GNFE. Then we can move on to Step 2.
      • At compile time, properties with no user provided keywords should start off as being unsafe. The parser can and should seal most of the bindings in order to have as many static sub-GBE-graphs and static sub-GNFE-graphs. When a binding changes, you would only need to change the pointers of the reassigned sub-GNFE-graphs to the new head value nodes.
      Possible Syntax => unsafe , Qt.unsafeBinding(..)
      	
       property var fib: (n)=> (n<=1)?1:fib(n-1)+fib(n - 2)
      
       property int a: 10
       unsafe property int b: fib(a) // Acts as a hintword to the QML compiler/parser
       unsafe property int c: fib(a+5)
       unsafe property var changeEvent: ( (v1,v2) => console.log("Values Changed { b:",v1,", c:",v2," }") ) (b,c) // <== This works in QML 2 too
      
       // QML3&QML2: Values Changed { b: 89 , c: 987  }
       //      QML2: Values Changed { b: 10946 , c: 987  } <== QML2 faulty intermediate value
       // QML3&QML2: Values Changed { b: 10946 , c: 121393  }
      
       Component.onCompleted: { a+=10;}
      

      As you can see there isn't really any need for changeValue signals. The binding evaluation engine can skip the middleman(aka signals) and make the function call themselves, even in QML 2.

      Signals can be superseded by changeEvents called by the binding engine
      	
      property var changeEvent2: ( (v1,v2,v3) => console.log("Values Changed { item_1.width:",v1,", item_2.height:",v2,", item_3.x:",v3,"}") ) (item_1.width,item_2.height,item_3.x) // <== This works in QML 2 too
      

      Furthermore you can have changeEvents based on values in multiple items, while value signals are handled one value at a time.

      Even better, with minor modifications the binding evaluation engine could also handle race conditions for competing events.

      Eg.

      Possible Syntax for a ValueEvent with subscribedTo and discardIf clauses to avoid race hazards
      	
      ValueEvent{ 
       id: moveEvent
       subscribedTo: OrClause{ values:[item.x,item.y] }
       discardIf: OrClause{ values: [item.width,item.height] }
       onTriggered: item.moveTo(item.x,item.y);
      }
      ValueEvent{ 
       id: redrawEvent
       subscribedTo: AndClause{ clauses: [OrClause{ values:[item.x,item.y]}, OrClause{ values: [item.width,item.height] } ] }
       onTriggered: item.reBufferAndDraw(item.x,item.y,item.width,item.height);
      } 
      
      • The changeEvents calls should be postponed after the binding evaluation engine finishes his job. Previous values should still be available when the valueEvents functions are called to check for value changes. Postpone deletion of previous values as long as possible.

      6) Runtime Cache commonly computed values specified by the developer. Keep them even if the ref count goes nil.

      Possible Syntax => inCache[...] , inCacheAll
      	
      // Caches the computed values 
      unsafe property int  spritePos: 1 inCache [1,2,3]
      sealed property int xForSprite: 100+spritePos*200 
      sealed property int yForSprite: 50+spritePos*75   
      // It will need them again 60 ms later; Delete the cached values when the sprite is deleted.
      
      Timer{ interval: 20; running: true; repeat:true; onTriggered: spritePos=1+spritePos%3; }
      
      Possible Syntax => inSave[...] , inSaveAll
      	
      // Saves computed values on disk 
      unsafe property bool useLayoutMirroring: true inSave [true,false]
      unsafe property bool darkMode: true inSaveAll
      // Loads them when you restart the app
      
      inSaveAll geometry of items on mobile
      	
      Window {
       id: rootWindow
       visible: true
       width: Screen.width   inSaveAll // This should be the default behavior for mobile and embedded apps
       height: Screen.height inSaveAll // Furthermore you should be able to ship those apps with precalculated values specified by the developer at compile-time. Eg. 1920x1080, 2340x1080, 2160x1080 etc.
      }
      
      • On mobile and embedded the dimensions and the anchoring of all items in a QML app can be broken down to expressions based on Screen.height and Screen.width. Images and Texts can be really tricky if they receive intermediate values. Texts in particular are even more troublesome. It takes some time to reach a final font.pixelSize when fontSizeMode is set to Text._Fit. It should be possible to save those values on first runs and use them in later reruns. It would be even better if you could ship the app with precalculated values. The app could perform multiple hidden runs just to get those values.

      7) Binding safety: Users should seal simple bindings if they don't plan to manually change them. Users should mark a property unsafe if the expression is overly complex or it changes value very often. They should use these 2 keywords to fine-grain between CPU use and memory use.

      • There's nothing wrong in using overly complex bindings, as long as they are marked unsafe and thus become pass-by-value. If for example, by mistake, you would seal a naive Fibonacci function binding, the NFE for it would simply be too large to hold in memory. There must caution in using both keywords.
      • If the user doesn't provide neither the unsafe or the sealed hintwords, the QML compiler/parser should step in and take calculated guesses. If the QML compiler isn't certain, it's better to left them unsafe to avoid possible memory overflows at compile-time or at runtime.

      Further considerations

      • I might be overly optimistic, but it might be possible to straightforwardly build the GNFE without the need of having a GBE around, but I'm not sure.
      • To my opinion, Q_PROPERTY( ... [NOTIFY *notifySignal*] ) is an extremely wasteful way to communicate value changes. Rarely, if ever, a user makes use of those signals. Moreover signals that communicate visual property changes rapidly clog the UI thread. They get emitted by the tens of thousands at a time and rarely does the user need any of them. I think there's a better do communicate between items and the scene graph: ValueEvents such as the one I described earlier.
      • Value notifying signals should be replaced by valueEvent functions that are called by the binding engine. I reiterate the proof of concept:
        Put this in main.qml and move the application window
        	
        property var valueEvent: ( (x,y)=>console.log(x,y) )(x,y) // <<== This works in QML 2
        

        A ValueEvent class like the one I described earlier, that uses logical clauses, would be able to handle the most complex race conditions. It would also be able to g*roup related values across multiple items*.

      • Items can subscribe to value changes of other items. The binding engine will deliverer those values when the are fully-baked. This assures that not a single call is wasted, not a single signal event is being sent declared without being needed.

      Attachments

        No reviews matched the request. Check your Options in the drop-down menu of this sections header.

        Activity

          People

            ulherman Ulf Hermann
            adrian.gabureanu Adrian Gabureanu
            Votes:
            1 Vote for this issue
            Watchers:
            10 Start watching this issue

            Dates

              Created:
              Updated:

              Gerrit Reviews

                There are no open Gerrit changes