Swift 高性能数值编程:探索与思考

技术
作者

Jeremy Howard

发布于

2019年1月10日

在过去几周,我一直在为 Swift 构建一些数值编程库。等等,Swift 不只是 iOS 程序员用来构建应用程序的吗?现在不是了!如今 Swift 可以在 Linux 和 Mac 上运行,可用于 Web 应用程序命令行工具以及你能想到的几乎任何其他事物。

将 Swift 用于数值编程(例如训练机器学习模型)的领域,目前从事的人并不多。关于这个主题的信息非常少。但在经过几周的研究和实验后,我成功创建了几个库,它们可以达到经过精心优化的矢量化 C 代码一样的速度,同时又简洁易用。在本文中,我将带你了解这段旅程,并展示我学到的关于如何有效使用 Swift 进行数值编程的知识。我将主要从我的 BaseMath 库中引用示例,该库为 FloatDouble 提供通用数学函数,并为它们的各种集合提供了优化版本。(在此过程中,我将对 Swift 和其他语言有很多看法,既有正面的也有负面的;如果你对你最喜欢的编程语言有着深厚的情感联系,并且不喜欢看到任何批评,你可能想跳过这篇文章!)

在未来的文章中,我还将展示如何通过与 Intel 的 C 性能库对接来获得额外的速度和功能。

背景

通常在新年左右,我都会尝试使用一种新的语言或框架。对我来说特别奏效的一种方法是看看那些构建了我最喜欢的语言、书籍和库的人现在在做什么。这种方法让我成为了 Delphi、Typescript 和 C#(在我使用过他的 Turbo Pascal 后,是 Anders Hejlsberg)、Perl(在我使用过 rn 后,是 Larry Wall)、JQuery(在我阅读了《现代 Javascript》后,是 John Resig)等的早期用户。所以当我得知 Chris Lattner(他编写了出色的 LLVM)正在创建一个名为 Swift for Tensorflow(我在这里简称 S4TF)的新深度学习框架时,我决定应该去看看。

请注意,S4TF 不是一个无聊的 Tensorflow Swift 包装器!这是我见过的第一个认真尝试可微分编程深度融入到广泛使用的语言核心的努力。我希望 S4TF 能为我们提供一种语言和框架,首次将可微分编程视为编程世界的一等公民,并允许我们做一些事情,例如:

  • 用 Swift 编写自定义 GPU 内核
  • 命名张量的轴名称和大小匹配提供编译时检查
  • 任意代码进行微分,同时自动提供矢量化和融合的实现。

这些功能目前在 S4TF 中尚不可用(事实上,该项目还处于早期阶段,几乎所有深度学习功能都尚未工作)。但我完全期待它们最终会实现,当那一刻到来时,我确信在 Swift 中使用可微分编程将比在任何其他语言中提供更好的体验。

我很幸运在最近的一次会议上偶然遇到了 Chris,当我告诉他我对 S4TF 的兴趣时,他非常友好地表示愿意帮助我入门 Swift。我一直认为,与谁合作对我的工作效率和幸福感的影响远远大于做什么,所以这是花时间在这个项目上的另一个绝佳理由。Chris 一直非常乐于助人,而且他超级好人——所以,谢谢你,Chris!

关于 Swift

Swift 是一种通用、多范式、编译型编程语言。它由 Chris Lattner 在 Apple 工作期间发起,并支持许多来自 Objective-C(Apple 设备编程主要使用的语言)的概念。Chris 将这种语言描述为“LLVM 的语法糖”,因为它与该编译器框架中的许多思想紧密对应。

我编程已有大约 30 年,期间使用过几十种语言(甚至为其中一些做出过贡献)。我总是希望在接触一门新语言时能找到一些开阔思维的新想法,而 Swift 绝对没有让人失望。Swift 力求表达力强、灵活、简洁、安全、易用且快速。大多数语言在至少一个领域会做出显著妥协。以下是我对一些我用过并喜欢的语言的个人看法,这些语言我在使用时有时会发现其局限性令人沮丧:

  • Python:运行时慢,对并行处理支持差(但非常易用)
  • C, C++:难以使用(且 C++ 编译时间慢),但速度快且(对 C++ 而言)表达力强
  • Javascript:不安全(除非使用 Typescript);有点慢(但易用且灵活)
  • Julia:对通用编程支持差,但对数值编程来说速度快且表达力强。(编辑:这可能对 Julia 有点不公平;自从我上次看它以来,它已经取得了长足的进步!
  • Java:冗长(但正在改善,特别是如果使用 Kotlin)、灵活性较低(由于 JVM 问题)、有点慢(但总体而言,是一门在许多应用领域都很有用的语言)
  • C# 和 F#:可能是所有主要编程语言中妥协最少的,但仍然需要安装运行时,由于垃圾回收限制了灵活性,并且难以使代码真正快速(除了在 Windows 上,可以通过 C++/CLI 接口)

我想说 Swift 在避免重大妥协方面做得相当不错(Rust 可能也是;我没有认真使用过它,所以无法做出明智的评论)。它在上述任何领域都不是最好的,但也相差不远。我不知道还有哪种单一语言能做出这样的声明(但请注意,它也有其缺点,我将在本文的最后一节讨论这些缺点)。我将简要地逐一介绍它们:

  • 简洁:这是如何创建一个新数组 b,将 2 添加到数组 a 的每个元素中:let b=a.map {$0+2}。这里,{$0+2} 是一个匿名函数,$0 是第一个参数的自动名称(你也可以选择添加名称和类型)。b 的类型会自动推断。正如你所见,我们用很少的代码就做了很多事情!
  • 表达力强:上面那行代码不仅适用于数组,还适用于任何支持特定操作(由 Swift 标准库中的 Sequence 定义)的对象。你也可以将对 Sequence 的支持添加到你的任何对象中,甚至添加到现有的 Swift 类型或其他库中的类型。一旦你这样做了,这些对象就会免费获得此功能。
  • 灵活:Swift 没有太多做不到的事情。你可以用它来开发移动应用、桌面应用、服务器代码,甚至是系统编程。它非常适合并行计算,也可以处理(某种程度上)小内存设备。
  • 安全:Swift 有一个相当强大的类型系统,它在注意到你做了一些行不通的事情时做得很好。它对可选值有很好的支持,而不会使你的代码变得冗长。但是当你需要额外的速度或灵活性时,通常有办法绕过 Swift 的检查。
  • 快速:Swift 避免了可能导致语言变慢的因素;例如,它不使用垃圾回收,允许你在几乎所有地方使用值类型,并尽量减少锁定的需求。它在后台使用 LLVM,这为生成优化机器代码提供了出色的支持。Swift 还让编译器很容易知道事物何时是不可变的,并避免别名,这也有助于编译器进行优化。正如你将看到的,你通常可以获得与精心优化的 C 代码相同的性能。
  • 易用:这可能是唯一一个有点妥协的领域。编写基本的 Swift 程序相当容易,但可能会出现一些难以理解的类型问题,距离问题实际发生地很远却出现神秘的错误消息,并且安装和分发应用程序和库可能会具有挑战性。此外,该语言一直在发生很多变化(而且是朝着更好的方向!),所以网上的大多数信息都已经过时,需要进行更改才能使其工作。尽管如此,它比像 C++ 这样的语言更容易使用。

面向协议编程

Swift 避免妥协的主要技巧是其使用面向协议编程。基本思想是我们尽量多地使用值类型。在大多数重视易用性的语言中,引用类型被广泛使用,因为它们允许使用垃圾回收、虚函数、重写超类行为等等。面向协议编程是 Swift 获取许多这些优点的方法,同时避免了引用类型的开销。此外,通过避免引用类型,我们避免了当我们有两个变量指向同一事物时引入的所有复杂错误。

值类型也非常适合函数式编程风格,因为它们能更好地支持不变性和相关的函数式概念。许多程序员,特别是在 Javascript 世界,最近才认识到如何通过利用函数式风格使代码更简洁、易懂和正确。

如果你使用过像 C# 这样的语言,你会熟悉这样的概念:用 struct 定义的东西会得到一个值类型,而使用 class 会得到一个引用类型。Swift 处理事情的方式也是如此。

在我们讨论协议之前,先提一下另外两个基础概念:自动引用计数(ARC)和写时复制(copy-on-write)。

自动引用计数(ARC)

引自文档:“Swift 使用自动引用计数(ARC)来跟踪和管理应用程序的内存使用。在大多数情况下,这意味着内存管理在 Swift 中‘just works’,你无需自己考虑内存管理。当类实例不再需要时,ARC 会自动释放它们使用的内存。”引用计数传统上被 Perl 和 Python 等动态语言使用。在现代编译型语言中看到它很不寻常。然而,Swift 的编译器努力仔细跟踪引用,而不引入开销。

ARC 对于处理 Swift 的引用类型(我们有时仍需要使用)和处理共享内存的写时复制语义的值类型对象或嵌入在引用类型中的对象的内存使用都非常重要。Chris 还向我提到了其他一些好处:它提供确定性的析构,消除了GC finalizers 的常见问题,允许缩放到不需要/不想要 GC 的系统,并消除了不可预测/不可重现的暂停。

写时复制

在大多数语言中,值类型的一个主要问题是,如果你有一个大型数组,你不会希望将整个数组传递给函数,因为那会需要大量的慢速内存分配和复制。所以在这种情况下,大多数语言会使用指针或引用。然而,Swift 会传递原始内存的引用,但如果引用修改了对象,只有这时才会进行复制(这是在后台自动完成的)。所以我们结合了值类型和引用类型的最佳性能特性!这被称为“写时复制”(copy-on-write),在一些 S4TF 文档中它被相当有趣地称为“COW 🐮”(是的,还带有牛脸表情符号!)。

写时复制也有助于函数式风格的编程,同时在需要时仍然允许修改——但没有不必要的复制开销或手动引用的冗长。

协议

对于值类型,我们不能使用继承层次结构来获得面向对象编程的好处(尽管如果使用引用类型,Swift 也支持,你仍然可以使用它们)。因此,Swift 转而提供了协议。许多语言,如 Typescript、C# 和 Java,都有接口的概念——描述对象可以包含哪些属性和方法。乍一看,协议与接口非常相似。例如,这是我的 BaseMath 库中 ComposedStorage 的定义,它是一个描述包装其他集合的集合的协议。它定义了两个属性:dataendIndex,以及一个方法:subscript(这是 Swift 中的一个特殊方法,提供索引功能,就像数组一样)。这个协议定义只是简单地说,任何遵守这个协议的东西必须提供这三个东西的实现。

public protocol ComposedStorage {
  associatedtype Storage:MutableCollection where Storage.Index==Int
  typealias Index=Int

  var data: Storage {get set}
  var endIndex: Int {get}
  subscript(i: Int)->Storage.Element {get set}
}

这是一个泛型协议。泛型协议不像泛型类那样使用 <Type> 标记,而是使用 associatedtype 关键字。因此,在这种情况下,ComposedStorage 表示 data 属性包含某种泛型类型,称为 Storage,它符合 MutableCollection 协议,而该类型又有一个关联类型(associatedtype)称为 Index,为了符合 ComposedStorage,其类型必须是 Int。它还表示 subscript 方法返回 StorageElement 关联类型包含的任何类型。正如你所见,协议提供了一个相当富有表现力的类型系统。

再看下去,你会看到别的东西……这个协议还提供了实现

public extension ComposedStorage {
  subscript(i: Int)->Storage.Element {
    get { return data[i]     }
    set { data[i] = newValue }
  }
  var endIndex: Int {
    return data.count
  }
}

这就是事情变得真正有趣的地方。通过提供实现,我们自动为任何符合此协议的对象添加了功能。例如,这是 BaseMath 中 AlignedStorage 的完整定义,这是一个提供类似数组功能的类,但内部使用对齐内存,这通常是快速矢量化代码所必需的:

public class AlignedStorage<T:SupportsBasicMath>: BaseVector, ComposedStorage {
  public typealias Element=T
  public var data: UnsafeMutableBufferPointer<T>

  public required init(_ data: UnsafeMutableBufferPointer<T>) {self.data=data}
  public required convenience init(_ count: Int)      { self.init(UnsafeMutableBufferPointer(count)) }
  public required convenience init(_ array: Array<T>) { self.init(UnsafeMutableBufferPointer(array)) }

  deinit { UnsafeMutableRawBufferPointer(data).deallocate() }

  public var p: MutPtrT {get {return data.p}}
  public func copy()->Self { return .init(data.copy()) }
}

正如你所见,代码并不多。然而,这个类提供了协议 RandomAccessCollection, MutableCollection, ExpressibleByArrayLiteral, EquatableBaseVector 的所有功能(这些协议共同包含了数百个方法,如 map, find, dropLast, 和 distance)。这是可能的,因为这个类所符合的协议 BaseVectorComposedStorage 通过协议扩展提供了此功能(直接提供,或通过它们自身符合的其他协议提供)。

顺便说一下,你可能注意到我将 AlignedStorage 定义为 class 而不是 struct,尽管我之前对值类型大加赞扬!重要的是要认识到,仍然有一些情况需要使用类。Apple 的文档提供了一些有用的指导。Structs(尚)不支持的一点是 deinit;也就是说,在对象销毁时运行一些代码的能力。在这种情况下,当对象用完时,我们需要释放内存,所以需要 deinit,这意味着我们需要一个类。

你发现真正需要使用协议的一个常见情况是,当你想要抽象类的行为时。Swift 完全不支持抽象类,但你可以通过使用协议获得相同的效果(例如,在上面的代码中,ComposedStorage 定义了 data 但未在协议扩展中实现它,因此它充当抽象属性)。多重继承也是如此:Swift 类不支持多重继承,但你可以符合多个协议,每个协议都可以有扩展(这在 Swift 中有时被称为mixins)。协议扩展与 Rust 中的traits和 Haskell 中的typeclasses有很多共同点。

Float 和 Double 的泛型支持

对于数值编程,如果你正在创建一个库,那么你可能希望它能透明地支持至少 FloatDouble。然而,Swift 并没有使这变得容易。有一个名为 BinaryFloatingPoint 的协议,理论上支持这些类型,但不幸的是,Swift 中只有三个数学函数为此协议定义(abs, max, 和 min - 以及标准数学运算符 +-*/)。

当然,你可以简单地为每种类型提供独立的功能,但这会让你必须创建所有东西的两个版本,你的用户也必须处理同样的问题。有趣的是,我没有在网上找到关于这个问题的讨论,而且 Swift 自己的库在多个地方都存在这个问题。正如以下讨论,Swift 根本没有被大量用于数值编程,而这些就是我们需要处理的问题。顺便说一句,如果你在网上搜索数值编程代码,你会经常看到 CGFloat 类型的使用(它受到 Objective-C 命名约定和限制的影响,我们稍后会详细了解),但这只支持 float 或 double 中的一种(取决于你运行的系统)。CGFloat 竟然存在于 Swift 的 Linux 移植版中,这相当奇怪,因为它只是为了苹果特定的兼容性而创建的;它几乎肯定不是你会想使用的东西。

解决这个问题其实相当直接,并且很好地展示了如何使用协议。在 BaseMath 中,我创建了 SupportsBasicMath 协议,如下所示:

public protocol SupportsBasicMath:BinaryFloatingPoint {
  func log2() -> Self
  func logb() -> Self
  func nearbyint() -> Self
  func rint() -> Self
  func sin() -> Self

}

然后我们告诉 Swift,Float 符合这个协议,我们也提供了方法的实现:

extension Float : SupportsBasicMath {
  @inlinable public func log2() -> Float {return Foundation.log2(self)}
  @inlinable public func logb() -> Float {return Foundation.logb(self)}
  @inlinable public func nearbyint() -> Float {return Foundation.nearbyint(self)}
  @inlinable public func rint() -> Float {return Foundation.rint(self)}
  @inlinable public func sin() -> Float {return Foundation.sin(self)}

}

现在在我们的库代码中,我们可以简单地使用 SupportsBasicMath 作为泛型类型的约束,然后直接调用所有常见的数学函数。(Swift 已经以透明的方式提供了对基本数学运算符的支持,所以我们不需要做任何事情来让它工作。)

如果你觉得编写所有这些包装函数肯定是一件很麻烦的事情,那么别担心——我使用了一个便捷的技巧,让计算机替我完成了。这个技巧是使用 gyb 模板,用 python 代码自动生成方法,如下所示:

% for f in binfs:
  func ${f}(_ b: Self) -> Self
% end # f

如果你查看 Swift 代码库本身,你会发现这种技巧被大量使用,例如用来定义基本数学函数本身。希望在未来的某个版本中,我们能在标准库中看到泛型数学函数。与此同时,只需使用 BaseMath 中的 SupportsBasicMath

性能技巧与结果

Swift 一个非常酷的地方在于像上面这样的包装器没有运行时开销。正如你所见,我用 inlinable 属性标记了它们,这告诉 LLVM 可以用实际的函数体替换对该函数的调用。这种 零开销抽象 是 C++ 最重要的特性之一;在像 Swift 这样简洁且富有表达力的语言中看到这一点真是太棒了。

让我们做一些实验来看看它是如何工作的,通过运行一个简单的基准测试:将 2.0 添加到 Swift 中包含 1,000,000 个浮点数的数组的每个元素。假设我们已经分配了适当大小的数组,我们可以使用这段代码(注意:benchmark 是 BaseMath 中的一个简单函数,用于测量一段代码的执行时间):

benchmark(title:"swift add") { for i in 0..<ar1.count {ar2[i]=ar1[i]+2.0} }
> swift add: .963 ms

在一毫秒内完成一百万次浮点数加法真是令人印象深刻!但看看我们做了一点微调后会发生什么:

benchmark(title:"swift ptr add") {
  let (p1,p2) = (ar1.p,ar2.p)
  for i in 0..<ar1.count {p2[i]=p1[i]+2.0}
}
> swift ptr add: .487 ms

代码几乎相同,但速度却快了两倍——这是怎么回事?BaseMath 为 Array 添加了 p 属性,该属性返回数组内存的指针;所以上面的代码使用的是指针,而不是数组对象本身。通常,由于 Swift 必须处理写时复制(COW)的复杂性,它无法完全优化这样的循环。但通过使用指针代替,我们跳过了这些检查,Swift 就能全速运行代码。请注意,由于写时复制,如果你对数组赋值,它可能会移动,如果你执行例如调整大小等操作,它也可能移动;因此,你应该只在你需要时获取指针。

上面的代码仍然相当笨拙,但 Swift 使我们能够轻松提供一个优雅且符合习惯用法的接口。我向 Array 添加了一个新的 map 方法,它将结果放入预先分配的数组中,而不是创建一个新数组。以下是 map 的定义(它使用了一些 BaseMath 的类型别名使其更简洁):

@inlinable public func map<T:BaseVector>(_ f: UnaryF, _ dest: T) where Self.Element==T.Element {
  let pSrc = p; let pDest = dest.p; let n = count
  for i in 0..<n {pDest[i] = f(pSrc[i])}
}

正如你所见,它是纯 Swift 代码。酷的是,这让我们现在可以使用清晰简洁的代码,并且仍然获得之前看到的速度:

benchmark(title:"map add") { ar1.map({$0+2.0}, ar2) }
> map add: .518 ms

我认为这非常了不起;我们能够创建一个简单的 API,它与指针代码一样快,但对于类用户来说,这种复杂性被完全隐藏起来了。当然,我们并不真正知道这有多快,因为我们还没有与 C 进行比较。所以接下来我们来做这件事。

使用 C

Swift 一个非常好的地方是添加你自己编写的 C 代码或使用外部 C 库非常容易。要使用我们自己的 C 代码,我们只需使用 Swift Package Manager (SPM) 创建一个新包,将一个 .c 文件放在其 Sources 目录中,并将一个 .h 文件放在其 Sources/include 目录中。(顺便说一句,在 BaseMath 中,那个 .h 文件完全是由 .c 文件使用 gyb 自动生成的!)这种级别的 C 集成是极其罕见的,其意义巨大。这意味着所有外部 C 库,包括操作系统内置的所有功能、优化的数学库、Tensorflow 的底层 C API 等等,都可以直接从 Swift 中访问。如果你出于任何原因需要自己编写 C 代码,那么你可以直接写,无需任何手动接口代码或额外的构建步骤。

这是我们的 C 语言求和函数(这是 float 版本——double 版本类似,两者都由一个 gyb 模板生成):

void smAdd_float(const float* pSrc, const float val, float* pDst, const int len) {
  for (int i=0; i<len; ++i) { pDst[i] = pSrc[i]+val; }
}

要调用这个函数,我们需要将 count 作为 Int32 传递;BaseMath 为数组添加了 c 属性用于此目的(或者你可以简单地使用 numericCast(ar1.count))。以下是结果:

benchmark(title:"C add") {smAdd_float(ar1.p, 2.0, ar2.p, ar1.c)}
> C add: .488 ms

它基本上和 Swift 的速度一样。这是一个非常令人鼓舞的结果,因为它表明我们可以使用 Swift 获得与优化过的 C 相同的性能。而且不仅仅是任何 Swift 代码,而是符合习惯且简洁的 Swift,通过诸如 reducemap 等方法,它看起来比大多数如此快的语言更接近数学方程式。

归约 (Reductions)

现在尝试另一个实验:计算数组的和。这是最符合 Swift 习惯的代码:

benchmark(title:"reduce sum") {a1 = ar1.reduce(0.0, +)}
> reduce sum: 1.308 ms

...这是用循环实现的相同功能:

benchmark(title:"loop sum") { a1 = 0; for i in 0..<size {a1+=ar1[i]} }
> loop sum: 1.331 ms

让我们看看我们之前提到的指针技巧这次是否也能有所帮助:

benchmark(title:"pointer sum") {
  let p1 = ar1.p
  a1 = 0; for i in 0..<size {a1+=p1[i]}
}
> pointer sum: 1.379 ms

这有点奇怪。速度并没有变快,这表明它没有获得最佳性能。让我们再次切换到 C,看看在那里它的表现如何:

float smSum_float(const float* pSrc, const int len) {
  float r = 0;
  for (int i=0; i<len; ++i) { r += pSrc[i]; }
  return r;
}

结果如下:

benchmark(title:"C sum") {a1 = smSum_float(ar1.p, ar1.c)}
> C sum: .230 ms

我将此性能与 Intel 优化的性能库版本 sum 进行了比较,发现它甚至比他们手动优化的汇编代码还要快!然而,要让它比 Swift 表现得更好,我确实需要知道一个小技巧(由 LLVM 的矢量化文档提供),即使用 -ffast-math 标志进行编译。对于这样的数值编程,我建议你始终至少使用这些标志(这些实验我只使用了这些,尽管你也可以添加 -march=native,并将优化级别从 O2 更改为 Ofast):

-Xswiftc -Ounchecked -Xcc -ffast-math -Xcc -O2

为什么我们需要这个标志?因为严格来说,由于浮点数的特性,加法不是关联的。但这在实践中,绝大多数人不太可能关心!默认情况下,clang 会使用“严格正确”的行为,这意味着它无法使用 SIMD 对循环进行向量化。但使用 -ffast-math,我们告诉编译器我们不介意将加法视为关联的(以及其他一些事情),因此它会向量化循环,使速度提高 4 倍。

对于像这样的 C 代码要获得良好性能,另一个需要记住的重要事项是确保你将所有不会更改的东西标记为 const,就像我在上面的代码中所做的那样。

不幸的是,目前似乎没有办法让 Swift 对任何归约操作进行矢量化。所以至少目前,我们必须使用 C 来获得良好的性能。这并不是语言本身的限制,只是 Swift 团队还没有来得及实现的优化。

好消息是:BaseMath 为 Array 添加了 sum 方法,该方法使用了这个优化的 C 版本,所以如果你使用 BaseMath,你会自动获得这个性能。所以测试 #1 的结果是:失败。我们未能让纯 Swift 达到与 C 相同的性能。但至少我们有了一个可以从 Swift 调用的不错的 C 版本。让我们进行另一个测试,看看通过避免进行任何归约操作是否能获得更好的性能。

临时存储

那么,如果我们想进行函数归约,例如平方和(sum-of-squares)呢?理想情况下,我们希望能够将上面的 map 风格与 sum 结合起来,但又不会受到 Swift 未优化的归约带来的性能损失。为了实现这一点,技巧是使用临时存储。如果我们使用上面的 map 函数将结果存储在预分配的内存中,然后就可以将其传递给我们的 C sum 实现。我们希望像静态变量一样存储预分配的内存,但这样就必须处理锁定来处理线程之间的竞争。为了避免这种情况,我们可以使用线程局部存储(TLS)。像大多数语言一样,Swift 提供了 TLS 功能;然而,它没有像 C# 那样将其作为核心语言的一部分,而是提供了一个类,我们可以通过 Thread.current.threadDictionary 访问它。BaseMath 将预分配的内存添加到这个字典中,并在内部以 tempStore 的形式提供;这便是单目函数归约的内部实现(还有二目和三目版本可用)。

@inlinable public func sum(_ f: UnaryF)->Element {
  self.map(f, tempStore)
  return tempStore.sum()
}

然后我们可以如下使用它:

benchmark(title:"lib sum(sqr)") {a1 = ar1.sum(Float.sqr)}
> lib sum(sqr): .786 ms

这比常规的 Swift reduce 版本提供了不错的速度提升:

benchmark(title:"reduce sumsqr") {a1 = ar1.reduce(0.0, {$0+Float.sqr($1)})}
> reduce sumsqr: 1.459 ms

这是 C 版本:

float smSum_sqr_float(const float* restrict pSrc, const int len) {
  float r = 0;
  #pragma clang loop interleave_count(8)
  for (int i=0; i<len; ++i) { r += sqrf(pSrc[i]); }
  return r;
}

让我们试试:

benchmark(title:"c sumsqr") {a1 = smSum_sqr_float(ar1.p, ar1.c)}
> c sumsqr: .229 ms

BaseMath 提供了所有标准单目数学函数的 C 实现的求和版本,因此你可以通过简单地使用以下方式调用上面的实现:

benchmark(title:"lib sumsqr") {a1 = ar1.sumsqr()}
> c sumsqr: .219 ms

总结一下:虽然使用临时存储(并仅调用 C 进行最终求和)的 Swift 版本比仅仅使用 reduce 快两倍,但使用 C 版本又快了 3 倍或更多。

不足之处

正如你所见,Swift 在数值编程方面有很多值得喜欢的地方。你可以获得优化过的 C 的性能,同时享受自动内存管理和优雅语法的便利。

我用过的最简洁灵活的语言是 Python。我用过的最快的语言是 C(好吧……实际上是 FORTRAN,但我们不说那个)。那么 Swift 如何与这些高标准相比呢?仅仅能够将一门语言与 Python 的灵活性和 C 的速度进行比较,本身就是一项了不起的成就!

总的来说,我的观点是,用 Swift 写我想写的代码,通常需要比 Python 多一些代码,而且抽象通用代码的方式也比较少。例如,我在 Python 中大量使用装饰器,并用它们为我编写大量代码。我大量使用 *args**kwargs (Swift 中新的动态特性可以提供一些这样的功能,但还没那么全面)。我可以一次性打包多个变量 (在 Swift 中,你需要为多个变量打包成对,然后使用嵌套括号进行解构)。然后,你还需要编写代码来让你的类型整齐地对齐。

我还发现 Swift 的性能比 C 更难推理和优化。C 在性能方面也有其自身的怪癖(例如需要使用 const,有时甚至需要 restrict 来帮助编译器),但它们通常有更好的文档、更容易理解、更一致。此外,clang 和 gcc 等 C 编译器使用诸如 omploop 等 pragmas 提供了强大的额外功能,甚至可以自动并行化代码。

尽管如此,Swift 在结合 Python 的表达力和 C 的速度方面,比我用过的任何其他语言都更接近。

仍然有一些问题需要注意。其中一点是,面向协议编程要求一种与你可能习惯的非常不同的方式。从长远来看,这可能是一件好事,因为学习新的编程风格可以帮助你成为更好的程序员;但这在最初几周可能会导致一些挫败感。

这个问题尤其具有挑战性,因为 Swift 的编译器经常不知道协议类型问题的真正来源在哪里,并且其猜测类型的能力仍然相当不稳定。因此,极小的更改,例如更改类型的名称,或更改类型约束的定义位置,都可能将原本工作正常的东西变成抛出四屏错误消息的东西。我的建议是尝试在独立的测试文件中创建类型结构的最小版本,并先在那里让其工作起来。

然而,请注意,易用性通常需要妥协。Python 特别容易,因为它非常乐意让你搬起石头砸自己的脚。Swift 至少会确保你先知道如何系鞋带。Chris 告诉我:最初构建 Swift 时的目标是,重要的优化方向是“端到端地实现你想要做的事情所需的正确实现的所需时间”。这包括编写代码的时间、调试的时间以及在你更改现有代码库时重构/维护的时间。 我还没有足够的经验,但我怀疑在这个指标上,Swift 会表现出色。

Swift 的某些部分我并不喜欢:由于 Apple 与 Objective-C 的历史造成的妥协、它的打包系统、它的社区以及缺乏 C++ 支持。或者更确切地说:我主要不喜欢的是 Swift 生态系统的某些部分。语言本身相当令人愉悦。而生态系统是可以改进的。但是,目前,这是 Swift 程序员必须面对的情况,所以让我们逐一 살펴보这些问题。

Objective-C

Objective-C 是一种在 20 世纪 80 年代开发的语言,旨在将 Smalltalk 的一些面向对象特性引入 C 语言。这是一个非常成功的项目,并被 NeXT 公司选为 1988 年 NeXTSTEP 编程的基础。随着 NeXT 被 Apple 收购,它成为了 Apple 设备编程的主要语言。今天,它显示出了它的时代感,以及将其作为 C 严格超集所带来的限制。例如,Objective-C 不支持真正的函数重载。相反,它使用一种称为选择器的东西,这仅仅是必需的关键字参数。每个函数的完整名称由函数名和所有选择器名称的串联组成。这个想法也被 AppleScript 使用,它提供了非常相似的功能,允许名称 print 在不同上下文中具有不同的含义。

print page 1
print document 2
print pages 1 thru 5 of document 2

AppleScript 反过来继承了这个想法自 HyperTalk,HyperTalk 是 1987 年为 Apple 深受喜爱(且已停产)的 HyperCard 程序创建的语言。考虑到所有这些历史,今天必需的命名参数这一概念在 Apple 大多数人中相当受重视,这并不令人惊讶。也许更重要的是,它为 Objective-C 的设计者提供了一个有用的折衷,因为他们避免了向语言添加真正的函数重载,从而保持了与 C 的紧密兼容。

不幸的是,这个限制今天仍在影响着 Swift,距离它在 Objective-C 中引入的情况已经过去了 40 多年。Swift 确实提供了真正的函数重载,这在数值编程中尤为重要,因为你真的不想为 floats、doubles 和复数(以及四元数等)创建完全独立的函数。但默认情况下,所有关键字名称仍然是必需的,这可能导致代码冗长且视觉上混乱。Apple 的风格指南强烈推广这种编码风格;他们的 Objective-C 和 Swift 风格指南彼此紧密呼应,而不是允许程序员真正利用 Swift 的独特能力。你可以通过在参数名称前加上 _ 来选择不要求命名参数,BaseMath 在所有不需要可选参数的地方都使用了这种方式。

另一个变得相当冗长的领域是处理 Foundation,这是 Apple 的主要类库,Objective-C 也使用它。Swift 的标准库缺少很多你需要的功能,所以你经常需要转向 Foundation 来完成任务。但这不会让你享受其中。在使用 Swift 这样一个设计优雅的语言的乐趣之后,用它来访问 Foundation 这样一个笨重的库会让人感到特别悲伤。例如,Swift 的标准库没有内置的方法来格式化具有固定精度的 floats,所以我决定将此功能添加到我的 SupportsBasicMath 协议中。代码如下:

extension SupportsBasicMath {
  public func string(_ digits:Int) -> String {
    let fmt = NumberFormatter()
    fmt.minimumFractionDigits = digits
    fmt.maximumFractionDigits = digits
    return fmt.string(from: self.nsNumber) ?? "\(self)"
  }
}

我们可以通过编写这样的扩展将此功能添加到 FloatDouble 中,这真是太酷了,而且使用 Swift 的 ?? 运算符处理转换失败的能力也很棒。但是看看使用 Foundation 的 NumberFormatter 类所需的代码有多么冗长!而且它甚至不接受 FloatDouble,而是接受来自 Objective-C 的笨拙的 NSNumber 类型(这本身就是为了解决 Objective-C 中缺乏泛型而采取的笨拙变通方法)。所以我不得不在 SupportsBasicMath 中添加一个 nsNumber 属性来进行类型转换。

Swift 语言本身确实支持更简洁的风格,例如 {f($0)} 风格的闭包。简洁对于数值编程很重要,因为它让我们可以编写更贴近所实现数学的代码,并一眼理解整个方程式。关于这一点(以及更多内容)的精彩阐述,请参阅 Iverson 的图灵奖演讲 符号作为思想的工具

Objective-C 也没有命名空间,这意味着每个项目都会选择一个两到三个字母的前缀添加到所有符号中。Foundation 库的大部分仍然使用继承自 Objective-C 的名称,所以你会发现自己在使用像 CGFloat 这样的类型和像 CFAbsoluteTimeGetCurrent 这样的函数。(每次我输入这些符号时,我敢肯定一只小独角兽都会痛苦地哭泣……)

Swift 团队做出了令人惊讶的决定,在 Apple 设备上运行 Swift 时使用 Objective-C 实现的 Foundation 和其他库,而在 Linux 上使用原生的 Swift 库。结果是,你有时会在不同平台看到不同的行为。例如,Apple 设备上的单元测试框架无法找到并运行作为协议扩展编写的测试,但它们在 Linux 下运行良好。

总的来说,我觉得 Objective-C 的限制和历史似乎经常渗透到 Swift 编程中,每次发生这种情况时,都会出现真实的摩擦。然而,随着时间的推移,这些问题似乎正在减少,我希望未来能看到 Swift 越来越摆脱 Objective-C 的束缚。例如,也许我们会看到真正努力为一些 Objective-C 类库创建符合 Swift 习惯的替代品。

社区

我在过去几年里大量使用 Python,让我一直感到困扰的一件事是,Python 社区中有太多人只用过那一门语言(因为它是一门很好的入门语言,并且广泛教授给本科生)。结果,缺乏对不同语言可以用不同方式做事情的认识,并且每种选择都有其优缺点。相反,在 Python 世界中,人们往往认为 Python 的方式是唯一的正确方式。

我在 Swift 中看到了类似的情况,但在某些方面甚至更糟:大多数 Swift 程序员的起点是 Objective-C 程序员。所以你在网上看到的很多讨论都是来自 Objective-C 程序员以一种与 Objective-C 中做法密切平行的方式编写 Swift。而且他们几乎所有编程工作都在 Xcode 中完成(这几乎可以肯定是我最不喜欢的 IDE,除了其出色的 Swift Playgrounds 功能),因此你在网上找到的很多建议都展示了如何通过让 Xcode 为你做事情来解决 Swift 问题,而不是自己编写代码。

大多数 Swift 程序员都在编写 iOS 应用程序,所以你也会找到很多关于如何布局移动 GUI 的指导,但关于如何在 Linux 上分发命令行程序或如何编译静态库等信息几乎没有。总的来说,由于 Swift 对 Linux 的支持还很新,关于如何使用它的信息不多,而且很多库和工具在 Linux 下不起作用。

大多数时候,当我追踪协议符合性的问题,或者试图弄清楚如何优化某段代码时,我能找到的唯一信息是 Apple Swift 语言团队成员之间的邮件列表讨论。这些讨论往往侧重于编译器和库的内部机制,而不是如何使用它们。因此,应用开发者讨论如何使用 Xcode 和 Swift 语言实现者讨论如何修改编译器之间存在巨大的空白。现在围绕 [https://forums.swift.org/] 的 Discorse 论坛正在形成一个不错的社区,希望随着时间的推移,它能成为 Swift 程序员有用的知识库。

打包与安装

Swift 有一个官方认可的包系统,称为 Swift Package Manager (SPM)。不幸的是,这是我用过的最糟糕的包系统之一。我注意到,几乎每种语言在创建包管理器时,都会从头开始重新发明一切,并且未能吸取之前尝试的成功和失败的经验。Swift 也遵循了这种不幸的模式。

市面上有一些非常出色的包系统。最好的,也许是 Perl 的 CPAN,它包括一个国际自动化测试服务,在各种系统上测试所有包,深度集成文档,有很棒的教程等等。另一个很棒(也更现代)的系统是 conda,它不仅处理特定语言的库(侧重于 Python),还自动安装兼容的系统库和二进制文件——并且所有操作都在你的主目录中进行,所以你甚至不需要 root 权限。它在 Linux、Mac 和 Windows 上都能很好地工作。它可以分发编译好的模块或源代码。

另一方面,SPM 没有这些系统的任何好处。尽管 Swift 是一门编译型语言,但它不提供创建或分发编译好的包的方法,这意味着你的包的用户必须安装构建它的所有先决条件。SPM 也不让你描述如何构建你的包,所以(例如)如果你使用 BaseMath,你需要自己记住在构建使用它的东西时添加获得良好性能所需的标志。

依赖项的处理方式非常尴尬。Git 标签或分支用于依赖项,而且没有在本地开发构建和打包版本之间切换的简便方法(例如,不像 pip-e 标志或 conda develop 命令)。相反,你必须修改包文件来更改依赖项的位置,并在提交之前记住切换回来。

要记录 SPM 的所有不足之处,所需时间太长;相反,你可以假设你现在使用的任何包系统所具备的任何有用特性,在 SPM 中可能都不存在。希望有人能着手为 Swift 建立一个基于 conda 的系统,这样我们都可以开始使用它来代替…

另外,Swift 的安装非常混乱。例如,在 Linux 上,只支持 Ubuntu,不同版本需要不同的安装程序。在 Mac 上,Swift 版本与 Xcode 版本以一种令人困惑和尴尬的方式绑定,命令行和 Xcode 版本有点分离,又有点关联,让我头疼。同样,conda 似乎是避免这种情况的最佳选择,因为单个 conda 包可以支持任何 Linux 版本,并且 Mac 也可以用同样的方式支持。如果能把 Swift 放到 conda 上,那么在任何系统上只需输入 conda install swift,一切就会就绪。这也将为版本控制、隔离环境和复杂的依赖跟踪提供解决方案。

(如果你在 Windows 上,目前你就运气不好了。有一个旧的非官方移植版到 Cygwin。Swift 在 Windows Subsystem for Linux 上运行良好。但很遗憾,目前还没有官方原生的 Windows Swift。不过在这方面有一些好消息:一位名叫 Saleem Abdulrasool 的英雄在完全独立地进行完整的原生移植方面取得了巨大进展,最近几天已经达到了 Swift 测试套件的绝大多数测试都通过的程度。)

C++

Apple 选择 Objective-C 作为其“带对象的 C”解决方案,而世界其他地方选择了 C++。最终,Objective-C 扩展也被添加到了 C++ 中,创建了“Objective-C++”,但未能统一这些语言中的概念,因此生成的语言是一个变体,带有许多显著限制。然而,该语言有一个很好的子集,可以绕过 C 的一些最大限制;例如,你可以使用函数重载,并访问丰富的标准库。

不幸的是,Swift 完全不能与 C++ 接口。即使是包含重载函数的简单头文件,也会导致 Swift 语言互操作失败。

这对数值程序员来说是一个大问题,因为现在许多最有用的数值库都是用 C++ 编写的。例如,PyTorch 核心的 ATen 库就是 C++ 写的。数值程序员倾向于 C++ 是有充分理由的:它提供了简洁而富有表达力地解决数值编程问题所需的功能。例如,Julia 程序员(理所当然地)为他们语言中支持关键的广播功能如此容易而自豪,他们在Julia challenge中记录了这一点。在 C++ 中,这个挑战有一个优雅且快速的解决方案。然而,你在纯 C 中找不到类似的东西。

这意味着,大量且越来越多的重要数值编程构建块对 Swift 程序员来说是遥不可及的。这是一个严重的问题。(你可以为 C++ 类编写简单的 C 包装器,然后创建一个使用这些包装器的 Swift 类,但这工作量非常大,而且非常乏味,我不确定有多少人愿意着手。)

其他语言已经展示了可能的解决方法。例如,Windows 上的 C# 通过 C++/CLI(C++ 的超集,支持 .Net)提供了“即插即用”(IJW)的接口功能。更有趣的是,CPPSharp 项目利用 LLVM 自动生成 C++ 代码的 C# 包装器,且没有调用开销。

解决这个问题对 Swift 来说并不容易。但是由于 Swift 使用 LLVM,并且已经与 C(和 Objective-C)建立了接口,它也许比几乎任何其他语言更有能力想出一个出色的解决方案。除了 Julia 之外,也许,因为他们已经这样做了两次

结论

Swift 是一门非常有趣的语言,它可以支持快速、简洁、富有表达力的数值编程。Swift for Tensorflow 项目可能是创建一个将可微分编程视为一等公民的编程语言的最佳机会。Swift 还使我们能够轻松地与 C 代码和库进行接口。

然而,Swift 在 Linux 上仍然不成熟,打包系统薄弱,安装笨拙,并且由于与 Objective-C 的历史渊源,库存在一些问题。

那么它表现如何呢?在数据科学领域,我们主要受限于使用 R(这是我用过的最不愉快的语言,但拥有设计最精美的数据处理和绘图库)或 Python(它速度慢得令人痛苦,很难并行化,但表达力极强,并且拥有最好的深度学习库)。我们确实需要另一个选择。一个快速、灵活,并且能与现有库良好互操作的语言。

总的来说,Swift 语言本身似乎正是我们所需要的,但很大一部分生态系统需要被替换或至少大幅提升。数据科学生态系统几乎不存在,尽管 S4TF 项目似乎有可能创建一些重要的部分。如果你有兴趣参与一个潜力巨大、有一群非常优秀的人正在努力实现目标的项目,并且你愿意帮助解决沿途遇到的问题,那么这是一个非常值得花时间的地方。