yorkie-team / yorkie

Yorkie is a document store for collaborative applications.
https://yorkie.dev
Apache License 2.0
783 stars 145 forks source link

Improve performance for creating crdt.TreeNode #930

Closed blurfx closed 3 months ago

blurfx commented 3 months ago

Description:

Currently, the time complexity of creating crdt.TreeNode is O(N^2). While this may not be a significant issue currently, there is a risk that as the number of tree nodes in the protobuf increases, operations will scale quadratically, potentially causing performance bottlenecks.

It would be beneficial to optimize the time complexity to prevent potential future performance issues.

https://github.com/yorkie-team/yorkie/blob/b468f8b711a1ca4144c6957be20be2cacda74c71/api/converter/from_pb.go#L571-L604

Why:

To prevent performance issues that could happen in the future.

raararaara commented 3 months ago

I tried implementing it in the following way.

func FromTreeNodes(pbNodes []*api.TreeNode) (*crdt.TreeNode, error) {    
    if len(pbNodes) == 0 {    
        return nil, nil    
    }    

    nodes := make([]*crdt.TreeNode, len(pbNodes))    
    for i, pbNode := range pbNodes {    
        node, err := fromTreeNode(pbNode)    
        if err != nil {    
            return nil, err    
        }    
        nodes[i] = node    
    }    

    root := nodes[len(nodes)-1]    
    depthTable := make(map[int32]*crdt.TreeNode)    
    depthTable[pbNodes[len(nodes)-1].Depth] = nodes[len(nodes)-1]    
    for i := len(nodes) - 2; i >= 0; i-- {    
        var parent *crdt.TreeNode = depthTable[pbNodes[i].Depth-1]    

        if err := parent.Prepend(nodes[i]); err != nil {    
            return nil, err    
        }    
        depthTable[pbNodes[i].Depth] = nodes[i]    
    }

    // build crdt.Tree from root to construct the links between nodes.    
    return crdt.NewTree(root, nil).Root(), nil    
}  

When benchmarking against trees of the form <p>aaaa...aa<p>, there was a performance improvement of about 20%.

Here's an explanation of how it works:

m4ushold commented 3 months ago

I tested raararaara's code by simply creating 10,000 child vertices

Test Code

func TestCreateNode(t *testing.T) {
    var chd []json.TreeNode
    for i := 0; i < 10000; i++ {
        chd = append(chd, json.TreeNode{Type: "p", Children: []json.TreeNode{{Type: "text", Value: "a"}}})
    }

    root := helper.BuildTreeNode(&json.TreeNode{
        Type:     "r",
        Children: chd,
    })

    pbNodes := converter.ToTreeNodes(root)

    for i := 1; i <= 10; i++ {
        t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) {
            converter.FromTreeNodes(pbNodes)
        })
    }
}

Current O(n^2) code result

Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestCreateNode$ github.com/yorkie-team/yorkie/api/converter

=== RUN TestCreateNode === RUN TestCreateNode/test_1 --- PASS: TestCreateNode/test_1 (0.38s) === RUN TestCreateNode/test_2 --- PASS: TestCreateNode/test_2 (0.32s) === RUN TestCreateNode/test_3 --- PASS: TestCreateNode/test_3 (0.31s) === RUN TestCreateNode/test_4 --- PASS: TestCreateNode/test_4 (0.31s) === RUN TestCreateNode/test_5 --- PASS: TestCreateNode/test_5 (0.31s) === RUN TestCreateNode/test_6 --- PASS: TestCreateNode/test_6 (0.35s) === RUN TestCreateNode/test_7 --- PASS: TestCreateNode/test_7 (0.32s) === RUN TestCreateNode/test_8 --- PASS: TestCreateNode/test_8 (0.31s) === RUN TestCreateNode/test_9 --- PASS: TestCreateNode/test_9 (0.33s) === RUN TestCreateNode/test_10 --- PASS: TestCreateNode/test_10 (0.31s) --- PASS: TestCreateNode (3.31s) PASS ok github.com/yorkie-team/yorkie/api/converter 3.321s

rararara's code result

Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestCreateNode$ github.com/yorkie-team/yorkie/api/converter

=== RUN TestCreateNode === RUN TestCreateNode/test_1 --- PASS: TestCreateNode/test_1 (0.17s) === RUN TestCreateNode/test_2 --- PASS: TestCreateNode/test_2 (0.19s) === RUN TestCreateNode/test_3 --- PASS: TestCreateNode/test_3 (0.16s) === RUN TestCreateNode/test_4 --- PASS: TestCreateNode/test_4 (0.15s) === RUN TestCreateNode/test_5 --- PASS: TestCreateNode/test_5 (0.15s) === RUN TestCreateNode/test_6 --- PASS: TestCreateNode/test_6 (0.15s) === RUN TestCreateNode/test_7 --- PASS: TestCreateNode/test_7 (0.15s) === RUN TestCreateNode/test_8 --- PASS: TestCreateNode/test_8 (0.15s) === RUN TestCreateNode/test_9 --- PASS: TestCreateNode/test_9 (0.15s) === RUN TestCreateNode/test_10 --- PASS: TestCreateNode/test_10 (0.16s) --- PASS: TestCreateNode (1.63s) PASS ok github.com/yorkie-team/yorkie/api/converter 1.637s

The time complexity is optimized from O(n^2) to O(n). Additionally, I added the missing root.Index.UpdateDescendantsSize().

func FromTreeNodes(pbNodes []*api.TreeNode) (*crdt.TreeNode, error) {
    if len(pbNodes) == 0 {
        return nil, nil
    }

    nodes := make([]*crdt.TreeNode, len(pbNodes))
    for i, pbNode := range pbNodes {
        node, err := fromTreeNode(pbNode)
        if err != nil {
            return nil, err
        }
        nodes[i] = node
    }

    root := nodes[len(nodes)-1]
    depthTable := make(map[int32]*crdt.TreeNode)
    depthTable[pbNodes[len(nodes)-1].Depth] = nodes[len(nodes)-1]
    for i := len(nodes) - 2; i >= 0; i-- {
        var parent *crdt.TreeNode = depthTable[pbNodes[i].Depth-1]

        if err := parent.Prepend(nodes[i]); err != nil {
            return nil, err
        }
        depthTable[pbNodes[i].Depth] = nodes[i]
    }

    root.Index.UpdateDescendantsSize()

    // build crdt.Tree from root to construct the links between nodes.
    return crdt.NewTree(root, nil).Root(), nil
}

Can I pr this code?

sejongk commented 3 months ago

@m4ushold Sure. I believe we can continue the discussion after you create the PR, if necessary.

CC) @blurfx

hackerwins commented 3 months ago

Before optimization:

Screenshot 2024-07-24 at 10 39 47 AM

After optimization:

Screenshot 2024-07-24 at 10 40 58 AM
hackerwins commented 3 months ago

With the introduction of depthTable, we get fromTreeNodes with O(N) time complexity. It would be good if it applied on the JS SDK as well.