Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GraphNode: batch dispose mode #83

Closed
kzhsw opened this issue May 4, 2023 · 9 comments · Fixed by #84
Closed

GraphNode: batch dispose mode #83

kzhsw opened this issue May 4, 2023 · 9 comments · Fixed by #84

Comments

@kzhsw
Copy link

kzhsw commented May 4, 2023

When using dedup or prune function in glTF-Transform, the functions could dispose a large amount of accessors, in a model with large amount of accessors, the dispose event listener in GraphNode#addRef consumes ~43% of the total CPU time.

screenshot

the screenshot of chrome devtools profiler
the screenshot of chrome devtools

Proposal

Add batch dispose mode to GraphNode, in this mode, dispose events are buffered and processed later, so less array filter and push is called.

The patch to property-graph

--- /orig/property-graph.esm.js        2023-04-17 08:45:39.767000000 +0800
+++ /patched/property-graph.esm.js    2023-05-04 14:56:49.944723600 +0800
@@ -312,6 +312,8 @@
     this.graph = graph;
     this[$immutableKeys] = new Set();
     this[$attributes] = this._createAttributes();
+    this.$disposedRef = {};
+    this.$batchDispose = false;
   }
   /**
    * Returns default attributes for the graph node. Subclasses having any attributes (either
@@ -506,14 +508,9 @@
     const refs = this[$attributes][attribute];
     return refs.map(ref => ref.getChild());
   }
-  /** @hidden */
-
+  disposeRef(ref, attribute, refs) {
+    if (!this.$batchDispose) {

-  addRef(attribute, value, attributes) {
-    const ref = this.graph.createEdge(attribute, this, value, attributes);
-    const refs = this[$attributes][attribute];
-    refs.push(ref);
-    ref.addEventListener('dispose', () => {
       const retained = refs.filter(l => l !== ref);
       refs.length = 0;

@@ -523,6 +520,49 @@
         type: 'change',
         attribute
       });
+      return;
+    }
+    let disposed = this.$disposedRef[attribute];
+    if (!disposed) {
+      this.$disposedRef[attribute] = disposed = [];
+    }
+    disposed.push(ref);
+  }
+
+  startBatchDispose() {
+    this.$batchDispose = true;
+  }
+
+  commitBatchDispose() {
+    for (let attribute in this.$disposedRef) {
+      let disposed = this.$disposedRef[attribute];
+      if (!disposed.length) {
+        continue;
+      }
+      let set = new Set(disposed);
+      disposed.length = 0;
+      const refs = this[$attributes][attribute];
+      const retained = refs.filter(l => set.has(l));
+      refs.length = 0;
+
+      for (const retainedRef of retained) refs.push(retainedRef);
+
+      this.dispatchEvent({
+        type: 'change',
+        attribute
+      });
+    }
+    this.$batchDispose = false;
+  }
+  /** @hidden */
+
+
+  addRef(attribute, value, attributes) {
+    const ref = this.graph.createEdge(attribute, this, value, attributes);
+    const refs = this[$attributes][attribute];
+    refs.push(ref);
+    ref.addEventListener('dispose', () => {
+      this.disposeRef(ref, attribute, refs);
     });
     return this.dispatchEvent({
       type: 'change',

The patch to @gltf-transform/functions

--- /orig/functions.modern.js	2023-05-04 14:42:15.841948800 +0800
+++ /patched/functions.modern.js	2023-05-04 15:03:40.144841900 +0800
@@ -1445,9 +1445,10 @@
       }
     });
   });
+  document.getRoot().startBatchDispose();
   Array.from(duplicateIndices.keys()).forEach(indices => indices.dispose());
   Array.from(duplicateAttributes.keys()).forEach(attribute => attribute.dispose()); // Dissolve duplicate animation sampler inputs and outputs.
-
+  document.getRoot().commitBatchDispose();
   for (const animation of document.getRoot().listAnimations()) {
     for (const sampler of animation.listSamplers()) {
       const input = sampler.getInput();
@@ -1463,8 +1464,10 @@
     }
   }
 
+  document.getRoot().startBatchDispose();
   Array.from(duplicateInputs.keys()).forEach(input => input.dispose());
   Array.from(duplicateOutputs.keys()).forEach(output => output.dispose());
+  document.getRoot().commitBatchDispose();
 }
 
 function dedupMeshes(document) {
@@ -1756,6 +1759,7 @@
     const disposed = {}; // Prune top-down, so that low-level properties like accessors can be removed if the
     // properties referencing them are removed.
     // Prune empty Meshes.
+    root.startBatchDispose();
 
     if (propertyTypes.has(PropertyType.MESH)) {
       for (const mesh of root.listMeshes()) {
@@ -1764,21 +1768,43 @@
         markDisposed(mesh);
       }
     }
+    root.commitBatchDispose();
 
     if (propertyTypes.has(PropertyType.NODE) && !options.keepLeaves) root.listScenes().forEach(nodeTreeShake);
+
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.NODE)) root.listNodes().forEach(treeShake);
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.SKIN)) root.listSkins().forEach(treeShake);
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.MESH)) root.listMeshes().forEach(treeShake);
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.CAMERA)) root.listCameras().forEach(treeShake);
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
 
     if (propertyTypes.has(PropertyType.PRIMITIVE)) {
       indirectTreeShake(graph, PropertyType.PRIMITIVE);
     }
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
 
     if (propertyTypes.has(PropertyType.PRIMITIVE_TARGET)) {
       indirectTreeShake(graph, PropertyType.PRIMITIVE_TARGET);
     } // Prune unused vertex attributes.
 
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
 
     if (!options.keepAttributes && propertyTypes.has(PropertyType.ACCESSOR)) {
       for (const mesh of root.listMeshes()) {
@@ -1795,6 +1821,8 @@
     // (3) Remove samplers orphaned in the process.
 
 
+    root.commitBatchDispose();
+
     if (propertyTypes.has(PropertyType.ANIMATION)) {
       for (const anim of root.listAnimations()) {
         for (const channel of anim.listChannels()) {
@@ -1814,14 +1842,27 @@
       }
     }
 
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.MATERIAL)) root.listMaterials().forEach(treeShake);
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.TEXTURE)) root.listTextures().forEach(treeShake);
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.ACCESSOR)) root.listAccessors().forEach(treeShake);
+    root.commitBatchDispose();
+
+    root.startBatchDispose();
     if (propertyTypes.has(PropertyType.BUFFER)) root.listBuffers().forEach(treeShake); // TODO(bug): This process does not identify unused ExtensionProperty instances. That could
     // be a future enhancement, either tracking unlinked properties as if they were connected
     // to the Graph, or iterating over a property list provided by the Extension. Properties in
     // use by an Extension are correctly preserved, in the meantime.
 
+    root.commitBatchDispose();
     if (Object.keys(disposed).length) {
       const str = Object.keys(disposed).map(t => `${t} (${disposed[t]})`).join(', ');
       logger.info(`${NAME$3}: Removed types... ${str}`);

screenshot after patch

screenshot profiler
screenshot code
screenshot code

Total blocking time reduced from ~26312ms to ~10332ms, GC time was also reduced after patch.

Additional context

The model contains ~30000 accessors.
The model contains some business stuff which can not be shared, but any model with many duplicated or unused accessors or nodes could reproduce.
This is not a breaking change since everything would work like not patched without calls to startBatchDispose or commitBatchDispose.
Should note that in batch dispose mode, less duplicated change events are dispatched.

@donmccurdy
Copy link
Owner

A while ago I'd been looking into using Set to represent reference lists rather than Array. Never completed the PR, but I wonder if that would be a better way to handle this? I don't know if I'm keen to introduce a batch mode specifically for disposing things. This should make the hot code in the dispose callback much faster I think.

Related:

@kzhsw
Copy link
Author

kzhsw commented May 5, 2023

Possible alternatives:

  1. Root#disposeRefs
class Root {
  disposeRefs<K extends RefListKeys<Attributes>>(attribute: K, refs: (Attributes[K]) => boolean | Attributes[K][]) {
    this.startBatchDispose();
    // do the dispose here
    this.commitBatchDispose();
  }
}
  1. delete and defragment, which does not require change to gltf-transform
class GraphNode {

  disposeRef(ref, attribute, refs) {
    let index = 0;
    let changed = false;
    while ((index = refs.indexOf(ref, index)) > -1) {
      refs[index] = null;
      changed = true;
    }
    // if (changed) this.dirtyMap[attribute] = true;
  }

  listRefs(attribute) {
    const refs = this[$attributes][attribute];
    let length = refs.length;
    let children = [], writeIndex = 0;
    for (let readIndex = 0; readIndex < length; readIndex++) {
      const ref = refs[readIndex];
      if (ref !== null) {
        if (readIndex !== writeIndex) {
          refs[writeIndex] = ref;
        }
        children.push(ref.getChild());
        writeIndex++;
      }
    }
    if (writeIndex !== refs.length) {
      refs.length = writeIndex;
      this.dispatchEvent({
        type: 'change',
        attribute
      });
    }
    return children;
  }

}
profiler

preformance
source listRefs
source disposeRef

Should note that this is optimized using inplace compact, which could also make commitBatchDispose faster.

Maybe for loops can be faster than indexOf.

  1. using data structures like binary tree or rope

Maybe too complex here.

@donmccurdy
Copy link
Owner

That we still need to rebuild the array of everything that wasn't disposed is unfortunate. Using Sets would really be ideal, to avoid:

for (const retainedRef of retained) refs.push(retainedRef);

But this requires breaking API changes I'm not ready to make right now. In the meantime I think the simplest and safest optimization may be something like this:

@donmccurdy
Copy link
Owner

Relevant for benchmarking, the model linked below contains about 40,000 accessors. Disposing 50% of those could be a comparable test.

https://sketchfab.com/3d-models/plant-3-5ecd9744ff6f4aa09766d796eec161ee

@kzhsw
Copy link
Author

kzhsw commented May 7, 2023

A much simplier idea for benchmarking

code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<button type="button" id="btn0">Benchmark</button>
<script type="importmap">
{
  "imports": {
    "@gltf-transform/core": "./packages/core/dist/core.modern.js",
    "property-graph": "./node_modules/property-graph/dist/property-graph.modern.js"
  }
}
</script>
<script type="module">
  import {Document} from '@gltf-transform/core';
  console.log(Document);

  /* Randomize array in-place using Durstenfeld shuffle algorithm */
  function shuffleArray(array) {
      for (var i = array.length - 1; i > 0; i--) {
          var j = Math.floor(Math.random() * (i + 1));
          var temp = array[i];
          array[i] = array[j];
          array[j] = temp;
      }
  }

  document.getElementById('btn0').onclick = () => {
      const doc = new Document();
      const count = 30000;
      const listRefs = 200;
      const root = doc.getRoot();
      // root.addRef()
      doc.createBuffer();
      // const buffer = doc.createBuffer();
      for (let i = 0; i < count; i++)
          doc.createAccessor();
      let accessors = root.listAccessors();

      const removeArray = [];
      for (let i = 1; i < count; i += 3) {
          removeArray.push(accessors[i]);
      }
      shuffleArray(removeArray);
      console.time('dispose benchmark');
      for (let i = 0, length = removeArray.length; i < length; i++) {
          removeArray[i].dispose();
      }

      for (let i = 0; i < listRefs ; i++)
          accessors = root.listAccessors();
      console.timeEnd('dispose benchmark');
      console.log(accessors.length === (count - removeArray.length),
          accessors.length, count, removeArray.length);
      console.log('done');
  };

</script>
</body>
</html>

Edit: benchmarking the inplace removal: https://jsben.ch/OxZiG

@donmccurdy
Copy link
Owner

While I fully agree it would be much faster, I don't think I can justify adding a batch API for disposal. We are dispatching events, user code may execute in those callbacks, and there's potential for this to go sideways in complex ways.

Storing refs in a Set is both faster and safer. I'd much rather do that, in a future major version. I the meantime I would prefer to take a safer option (#84) for a smaller speed improvement.

That said — it might help to understand better the kind of processing you're doing with glTF Transform, your performance goals, and what is working well vs. not well in general? Please feel free to start a thread if that would be helpful.

@kzhsw
Copy link
Author

kzhsw commented May 9, 2023

We are dispatching events, user code may execute in those callbacks, and there's potential for this to go sideways in complex ways.

Is the change event required to be fired every time disposing GraphNodes? The change event contains only the attribute, not the disposing node.
Maybe off-topic, but since dispatchEvent takes considerable cpu time in results above, maybe users could have options to choose which events they use, to have the trade-off between feature and performance, where all events enabled by default for backward-compatibility.

@donmccurdy
Copy link
Owner

We can't turn off dispatchEvent entirely — a few internals rely on the create and dispose events — but I think an option to disable change events would be possible.

@donmccurdy
Copy link
Owner

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants