【问题标题】:First steps in Swift, performance issue when allocating small objects in a BSTSwift 的第一步,在 BST 中分配小对象时的性能问题
【发布时间】:2016-04-17 12:55:17
【问题描述】:

在尝试学习 Swift 2.2 时,我在尝试分配许多小对象(基本上是 262144 个元素的 BST)时面临严重的性能下降。我当前的基准测试是我几年前编写的一个旧片段的 Java 1.8.0_74 编译,在我的 2012 Retina Macbook Pro 上,它在 59 秒(59036178 微秒)内执行。我可以通过 Instruments 观察到的问题是,每次迭代我都会得到几十个 swift_retain_ 和 swift_release。不知道如何避免它们:

import Foundation
import Darwin;

import Foundation

public class BinarySearchTree<T : Comparable> {
    private var _value : T?;

    private var _leftTree : BinarySearchTree<T>?;
    private var _rightTree : BinarySearchTree<T>?;

    public init(value : T) {
        _value = value;
    }

    var value : T? {
        get {
            return self._value;
        }
        set {
            self._value = newValue;
        }
    }

    var leftTree : BinarySearchTree<T>? {
        get {
            return self._leftTree;
        }
        set {
            self._leftTree = newValue;
        }
    }

    var rightTree : BinarySearchTree<T>? {
        get {
            return self._rightTree;
        }
        set {
            self._rightTree = newValue;
        }
    }

    public func add(newValue : T) -> BinarySearchTree<T> {
        var navigator : BinarySearchTree<T>?;
        var subtree : BinarySearchTree<T>?;

        var done : Bool?;

        done = false;
        navigator = self;

        while (!done!) {
            if (newValue < navigator?.value) {
                subtree = navigator?.leftTree;
                if (subtree != nil) {
                    navigator = subtree;
                } else {
                    let newNode = BinarySearchTree<T>(value: newValue);
                    navigator!.leftTree = newNode;
                    done = true;
                }
            } else if (newValue > navigator?.value) {
                subtree = navigator?.rightTree;
                if (subtree != nil) {
                    navigator = subtree;
                } else {
                    let newNode = BinarySearchTree<T>(value: newValue);
                    navigator?.rightTree = newNode;
                    done = true;
                }
            } else {
                done = true;
            }
        }
        return self;
    }
} /* cut remove/search methods */

这是我为测试运行编写的测试代码

let count : Int32 = 262144;
let base : Int32 = 65536;
let target : Int32 = count + 1;

var info = mach_timebase_info(numer:0, denom:0);
var timebase = mach_timebase_info(&info);
let numer = UInt64(info.numer);
let denom = UInt64(info.denom);
let norm = UInt64(numer/denom);

let check1 = (mach_absolute_time() * norm);

var root = BinarySearchTree<Int32>(value:base);

for var loop in 0 ... count-1 {
    if (loop % 1000 == 0) {
        print(loop);
    }
    root = root.add(loop);
}

let check2 = (mach_absolute_time() * norm);
print("Creation phase microseconds: [" + String((check2 - check1) / 1000) + "]");

我尝试搜索特定的快速释放/保留问题,但没有成功,我不知道如何继续。谢谢大家

【问题讨论】:

  • 你确定分配是真正的问题吗?您按递增顺序插入数字,这会使 BST 退化为链表并导致查找速度变慢。 – 如果你插入随机数会发生什么?
  • 嗨马丁,我知道退化很快,但这只是一个检查内存分配的测试循环。最坏的情况是,262k 元素的列表多年来仍然无法匹配任何 Mac。我使用类而不是结构(不好,我知道)以避免不必要的内存复制。不用说,-Ofast 和 Release 构建。
  • 我改为使用root = root.add(Int32(arc4random_uniform(UInt32(count)))) 运行您的代码,它在大约 0.5 秒内完成(发布配置)。分配的数量大致相同! – 这就是为什么我认为线性查找是问题,而不是分配。
  • 这确实很有趣。即使考虑到在 add 方法中会跳过重复值,它确实将问题移向了不同的方向,但是对于这么小的数据集,指针/引用导航真的会成为问题吗?对于Java版本肯定不行
  • 您是否在 Java 中尝试过相同的操作,即将 262144 递增的数字插入到具有相同算法的 BST 中?花了多长时间(与 262144 个随机数相比)?

标签: swift memory-management


【解决方案1】:

正如您所注意到的,问题是保留/释放(虽然它不是真的,保留/释放在 ummm 的力量旁边是微不足道的......我们将在最后到达那里)。这实际上与分配无关。您没有分配额外的对象,您只是暂时保留它们然后释放它们。我将从 Kenneth 的代码开始,它优化了原始代码中的许多性能问题,但仍然存在这个问题。 (我不考虑递归代码,因为它在您当前的用例中崩溃。不过,它确实避免了一些多余的保留。)

值得一提的是,Kenneth 的代码很好,通常是你应该做的事情(随着我们的进展你会看到更多)。

第一个注意事项:当您提到 -Ofast 时,那是针对 ObjC,而不是 Swift。 Swift 的标志是 -O。你也想要-whole-module-optimization,但这在这里并没有什么帮助。

还有一件小事,然后我们开始做。随时标记课程final。这确保没有动态调度。与保留/释放相比,这在此处无关紧要,但是,嘿,拿简单的东西。

30% 的折扣听起来不错?

好的,现在是一个大的,这是一个技巧。我发现我可以通过重写这个来淘汰大约 30% 的时间(从 ~6min 到 ~4min 进行完全导入):

guard let subtree = navigator.leftTree else {
    navigator.leftTree = BinarySearchTree<T>(value: newValue)
    break
}
navigator = subtree
continue

这样:

let subtree = navigator.leftTree
if subtree == nil {
    navigator.leftTree = BinarySearchTree(value: newValue)
    break
}
navigator = subtree!
continue

这是一件需要非常小心的事情。在这种情况下结果会更快,但在其他输入中可能没有那么快。对优化器的更改可能不会那么快(SIL 生成有点奇怪,我怀疑实际上可能是一个错误,因为它似乎在第二种情况下双重保留 navigator,但仅在 if 之后已成功)。但它目前似乎确实更快。 (编辑:Swift 团队对这一发现感到惊讶,现在有 a bug opened 反对它。不要指望这在未来会起作用。)

85% 怎么样?听起来怎么样?

但是就像你说的,我们不能用结构来避免这一切吗?但是每次我们触摸它时复制整棵树都会非常昂贵。当然,我们可以通过像 Array 使用的写时复制来显着改善这一点。但是COW相当复杂。如果只有一种方法可以重用现有的东西。如果我们使用数组呢?

private struct Node<Element: Comparable> {
    let value: Element
    var leftIndex = -1 // Ugly, but ~25% faster than using Int? in my tests
    var rightIndex = -1
    init(_ value: Element) { self.value = value }
}

// This works exactly the same if you make it a `final class`. Your choice.
public struct BinarySearchTree<Element: Comparable> {
    private var storage: [Node<Element>] = []

    init(value: Element) { storage.append(Node(value)) }

    public mutating func add(newValue: Element) {
        if storage.isEmpty {
            storage.append(Node(newValue))
        }

        var index = 0

        while (true) {
            let node = storage[index]
            if (newValue < node.value) {
                if node.leftIndex < 0 {
                    storage.append(Node(newValue))
                    storage[index].leftIndex = storage.count - 1 // Don't use node here; remember value types!
                    break
                }
                index = node.leftIndex
                continue
            } else if (newValue > node.value) {
                if node.rightIndex < 0 {
                    storage.append(Node(newValue))
                    storage[index].rightIndex = storage.count - 1
                    break
                }
                index = node.rightIndex
                continue
            } else {
                break
            }
        }
    }
}

这需要大约 45 秒才能在我的系统上运行。当然,这使delete 更加复杂。您要么必须接受“泄露”的内存(可能需要定期重新打包),要么需要维护一个空闲列表。但是添加一个 freelist 不会太难。

让我们在不更改 add() 的情况下尝试 99.97% 的改进。

当然,重要的是要记住,这对于 BST 来说几乎是病态的案例。即使您经常按顺序传递数据,您最好在插入之前应用随机播放,甚至包括随机播放的成本。例如,使用shuffleInPlace(并计算其时间),插入完全相同的值:

var values = Array(0 ... count - 1)
values.shuffleInPlace()
for (loop, value) in values.enumerate() {
    if (loop % 1000 == 0) {
        print(loop)
    }
    root.add(value)
}

这需要我们从 45 秒到大约 0.1 秒。 (Kenneth 的版本和我的“!”版本在这个指标下大约是 0.2s;我可能会使用 Kenneth 的解决方案,并添加了final。即使是你的原始代码,Kenneth 修复了很多低效率的问题,也只需要 0.5 s 与此更改。请记住,按顺序添加的 Kenneth 优化版本在我的系统上是 6 分钟。)

在插入之前洗牌是值得的。如果你随着时间的推移得到一些东西,那么在插入之前将它们分批并洗牌可能是值得的。如果树随着时间的推移发生变化,则值得检查它是否变得太深并定期重建它。保持树深度合理会压倒所有其他优化。解决 Swift 内存管理的巧妙方法无法触及这一变化。

修正算法。相比之下,其他一切都是小菜一碟。

【讨论】:

    【解决方案2】:

    我简化了您的代码,删除了一些 Optionals 和您的 getter/setter,因为它们是不必要的并且可能会导致代码变慢。

    我分析了你和我的代码,并在相同的随机元素数据集上得到了这个结果:

    1000 个元素:

    你的:创建阶段微秒:[28680771]

    我的:创建阶段微秒:[8564279]

    10000 个元素:

    你的:创建阶段微秒:[426233689]

    我的:创建阶段微秒:[126725800]

    这是我的代码:

    public class BinarySearchTree2<T : Comparable> {
      public init(value : T) {
        self.value = value
      }
    
      var value : T
      var leftTree : BinarySearchTree2<T>?
      var rightTree : BinarySearchTree2<T>?
    
      public func add(newValue : T) -> BinarySearchTree2<T> {
        var navigator = self
    
        while (true) {
          if (newValue < navigator.value) {
            guard let subtree = navigator.leftTree else {
              navigator.leftTree = BinarySearchTree2<T>(value: newValue)
              break
            }
            navigator = subtree
            continue
          }
          if (newValue > navigator.value) {
            guard let subtree = navigator.rightTree else {
              navigator.rightTree = BinarySearchTree2<T>(value: newValue)
              break
            }
            navigator = subtree
            continue
          }
          break
        }
        return self
      }
    } /* cut remove/search methods */
    

    编辑:

    我还做了一个更优化的平衡树测试,我创建了一个包含 1001 个顺序元素的数据集,删除了中间元素,使用 Fisher-Yates 洗牌来随机化顺序,用中间元素初始化根,然后运行两者套。这是我的结果:

    你的:创建阶段微秒:[27648219]

    我的:创建阶段微秒:[8332361]

    编辑 2:

    我将add() 方法切换为使用递归并显着提高速度:

    之前(我的原始代码):创建阶段微秒:[8088804]

    之后:创建阶段微秒:[1179398]

    这是新代码:

    public class BinarySearchTree3<T : Comparable> {
      public init(value : T) {
        self.value = value
      }
    
      let value : T
      var leftTree : BinarySearchTree3<T>?
      var rightTree : BinarySearchTree3<T>?
    
      public func add(newValue : T) {
        if (newValue < self.value) {
          if self.leftTree?.add(newValue) == nil {
            self.leftTree = BinarySearchTree3<T>(value: newValue)
          }
          return
        }
        if (newValue > self.value) {
          if self.rightTree?.add(newValue) == nil {
            self.rightTree = BinarySearchTree3<T>(value: newValue)
          }
          return
        }
      }
    } /* cut remove/search methods */
    

    【讨论】:

    • 感谢您的代码。现在它肯定变得更快了,我知道我还有很多实验要做
    • Optionals 非常强大,但它们确实要付出一定的代价,测试和展开会占用资源。明智地使用它们可以使您的代码更安全。计算属性也是如此。
    • 我明白了,我认为除了给出一个很好的例子之外,你的解释肯定会让像我这样试图从 Java 跳到 swift 的人的生活更轻松。 swift 版本仍然比 Java 慢 5 倍,但我确信这是因为如果不接受 swift 的语言特性,就不可能真正进行转码
    • 将您的 add() 方法切换到递归显示了显着的收益,请参阅我的新编辑。
    • 这减少了运行次数,我想。 Java 递归版本堆栈溢出,并且 BinarySearchTree3 Segfaults 与代码 11,我敢打赌是相同的。为了完整起见,我还观察到另一个有趣的行为:对于我的版本和您的 BinarySearchTree2,活动监视器正确地为应用程序提供了 100% 的 CPU 时间,但 CPU 检查器表明我有 4 个核心,占 25%。我开始认为问题可能出在操作系统/调度程序级别
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-11-25
    • 2019-06-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多