一、前言
- 数组对于大部分语言而言,都是相同数据类型的元素的集合,是非常常见的一种数据类型,正因为它非常常见,所以针对它的优化往往能达到立竿见影的效果。
- 为了运行测试代码,可以通过 Xcode→File→New→Project→command line tool 创建一个命令行程序工程,并新建一个任意名字的 swift 文件进行编辑开发。如果是在非 Mac 环境下通过 swift 命令行进行编译,可以编辑一个 swift 文件后,用 swiftc 命令行进行编译,如文件 main.swift,则编译命令为 swiftc main.swift,然后运行 ./main。
二、需求分析
- 假设项目中需要一个非常大的数组来存放数据,数组的元素是基础元数据类型(比如 Int),有时候需要从这大块数组中获取同样不小的子数组,并且获取结果数组。
- 定义源数组变量名为 array,赋值数据给它 10000000(一千万),足够用来测试性能:
let arrayCount = 10000000
var array: [Int] = Array(repeating: 0, count: arrayCount)
for i in 0..<array.count {
array[i] = i
}
- 定义一个数组变量 dest 用来接收数据,用 dest 来接收 array 的子数组,要求它获得 array 的前 5000000 (五百万)个数据元素:
let destCount = 5000000
var dest: [Int] = []
三、问题求解
① for 循环求解
- 只要先给 dest 赋值成指定大小的数组,然后用 for 循环挨个赋值即可:
print("engine = t1")
let t1 = Date()
dest = Array(repeating: 0, count: destCount)
for i in 0..<destCount {
dest[i] = array[i]
}
let t2 = Date()
print("time = \(t2.timeIntervalSince(t1))")
print("index of \(1024) = \(dest[1024])")
- 这些代码的逻辑简单明了,也非常好懂。为了测试代码的性能,我们通过 Date 类型的 t1 和 t2 用来计算求解过程的运行时间,最后在运行完毕后,再通过获取 dest 下标 1024 的值来验证结果是否正确(并非严谨验证,仅仅是示例)。
- 在机器上测试,结果如下:
engine = t1
time = 1.131295919418335
index of 1024 = 1024
- 从打印出来的结果可以看到,在本机环境下,处理时间需要一秒钟左右。
② 数组内置的区间运算符求解
- 事实上,Swfit 数组提供了一个非常方便的在内置函数,该函数声明如下:
subscript(Range<Int>) -> ArraySlice<Element>
- 该函数的功能正是提供一个数组的子数组,完美符合要求,该函数是一个区间运算符函数,它的效果和函数调用是一样的,只是调用方法上是通过例如 [a…<b] 的方法来完成。
- 另外,该函数返回的不是数组类型,而是一个被称为 ArraySlice 的类型,该类型描述了原数组中的一个区间数据,这样就避免了计算时直接拷贝出一个数组的性能消耗,因为调用者可能并不需要获取拷贝,只想拿到区间。
- 而当前解题需求是需要拿到数组拷贝,所以要补充额外的代码,具体如下:
print("engine = t2")
let t1 = Date()
dest = array[0..<destCount].map { $0 }
let t2 = Date()
print("time = \(t2.timeIntervalSince(t1))")
print("index of \(1024) = \(dest[1024])")
- 在该方法中,用 array[0…<destCount] 获取子数组区间类型 ArraySlice,再利用 map 方法生成新的数组,数组的元素值正是数组元素的值 Int,所以直接用内置变量 $0 即可完成操作。
- 运行代码,观察打印结果:
engine = t2
time = 0.45145297050476074
index of 1024 = 1024
- 该方法足足比“for 求解”的方法快 2.5 倍,性能提升了一大步。而且,该方法还有一个很大的优点,不需要单独写一行代码来创建 dest 数组,一行代码就解决了一切。
- 关于 map { $0 } 这段代码,是 swift 特有的语法糖,事实上它就是一个普通的名字为 map 的函数,该函数接收一个回调函数作为参数,这个参数通过 { $0 } 提供,该回调函数会提供原数组(或者迭代器)的每一个元素作为参数,然后要求返回一个值,返回的值会作为 map 返回的数组的元素值。而 $0 正是代表着迭代的每个元素,因为要返回的正是该元素的类型,所以直接返回即可。又因为 swift 的语法机制规定当单独提供一个语句时,该语句可以作为返回值,所以又省去 return 语法,最后就是看到的 { $0 } 。
③ while 循环求解
- 考察前面两个方法的时候,很容易都发现它们都用了区间运算 begin…<end,事实上区间运算好用归好用,但是性能理不理想却是另外一回事,现在来测试一下,放弃区间运算会如何,由于 for 循环解法是肯定需要区间运算的,把第一种解法用的是 “for 循环”改成 “while 循环”,看看性能怎么样:
print("engine = t3")
let t1 = Date()
dest = Array(repeating: 0, count: destCount)
var i = 0
while i < destCount {
dest[i] = array[i]
i += 1
}
print("time = \(t2.timeIntervalSince(t1))")
print("index of \(1024) = \(dest[1024])")
- 这是一个非常简单的 while 拷贝,来看看结果:
engine = t3
time = 0.18484008312225342
index of 1024 = 1024
- 运行时间大概 0.18 秒,比上一个方法快 2.44 倍,比 “for 循环”方法快 6.12 倍,从这里似乎可以得出结论,在 swift 里遇到性能敏感的领域,while 比 for 往往要更可靠。性能方面已经相当好了,但是还有更高效的办法吗?
④ 内存复制
- 在应用层开发上,涉及到数据之间的拷贝,直接的内存拷贝在性能上总是拔群的,因为它省去了中间计算和转换的过程,直接一比一的把一块内存数据复制给另一块内存。这个道理在某些语言上不一定行得通,因为某些语言并不对外规定元数据在内存中的字节序列是如何存放的,如果这一层被屏蔽了,内存复制就无从谈起。
- 幸运的是,swift 是提供了基础数据在内存中的映射关系的。如对于 [Int] 类型,它在内存中就是按连续的从低位到高位的 Int 存放的,而每个 Int 都占据固定的字节数(针对 64 位机编译的结果是 64 位,针对 32 位机编译的结果是 32 位)。了解了这个原理,要把一个整形数组(或者其中一部分连续空间)的值复制给另一个数组,直接进行内存复制就可行。
- 由于 swift 可以直接调用 C 的标准函数库,那么就可以直接用 memcpy 这个内存拷贝函数来解决问题,该函数要求提供的前两个参数分别是目标数据的地址和源数据的指针,可以通过 & 运算符获取,最后一个参数要求提供复制的字节数(注意:destCount 仅仅是数组的元素个数,并不是字节数):
print("engine = t3")
let t1 = Date()
dest = Array(repeating: 0, count: destCount)
memcpy(&dest, &array, destCount*MemoryLayout<Int>.size)
print("time = \(t2.timeIntervalSince(t1))")
print("index of \(1024) = \(dest[1024])")
字节数 = 单个数组元素的字节数 x 数组元素个数
- 因此第三个参数应该传递的是 destCount 乘以 Int 的字节数,Int 的字节数是多少呢?不要想当然的假设为 4 字节,需要通过泛型函数 MemoryLayout 来获取实际的字节数。
- 运行程序,查看运行时间:
engine = t4
time = 0.015346050262451172
index of 1024 = 1024
- 运行时间 0.015 秒,比起“while 循环”的方法还要快 12 倍,比“for 循环”方法更是快 73 倍。
⑤ 方案综合
let arrayCount = 10000000
var array: [Int] = Array(repeating: 0, count: arrayCount)
for i in 0..<array.count {
array[i] = i
}
for method in ["t1", "t2", "t3", "t4"] {
var dest: [Int] = []
let destCount = 5000000
let t1 = Date()
print("engine = \(String(describing: method))")
if method == "t1" {
dest = Array(repeating: 0, count: destCount)
for i in 0..<destCount {
dest[i] = array[i]
}
}
if method == "t2" {
dest = array[0..<destCount].map { $0 }
} else if method == "t3" {
dest = Array(repeating: 0, count: destCount)
var i = 0
while i < destCount {
dest[i] = array[i]
i += 1
}
} else if method == "t4" {
dest = Array(repeating: 0, count: destCount)
memcpy(&dest, &array, destCount*MemoryLayout<Int>.size)
}
let t2 = Date()
print("time = \(t2.timeIntervalSince(t1))")
print("index of \(1024) = \(dest[1024])")
}
- 现在可以一次性比较四种方法的性能差异,开始运行程序:
engine = t1
time = 1.0509920120239258
index of 1024 = 1024
engine = t2
time = 0.4606509208679199
index of 1024 = 1024
engine = t3
time = 0.15001296997070312
index of 1024 = 1024
engine = t4
time = 0.009117960929870605
index of 1024 = 1024
四、编译器优化
- 到目前为止,已经比较了四种方法在提取数组个数为 5000 万时的性能差异,但这真的就是“标准答案”吗?其实不尽然,因为 swift 编写的代码毕竟不是机器码,根据不同的编译器选项,它们编译生成的最终码也不相同,这里面自然会有很细微的差异,那么差异会有多大呢?
- 继续来做一个实验,swift 编译器带有一个专门优化速度的编译选项(代价是增加编译时间)。编译选项选择速度优化,可以在 Xcode 中可以通过点击“工程栏→Target→Swift Compiler - Code Generation→Optimization Level”选择 Optimize for Speed。如果不是在 Mac 环境下,没有用 Xcode 而是用 swiftc 来编译程序,该怎么办呢?其实,只需要直接运行命令 swiftc -O 文件名 .swift 即可。
- 打开优化选项后,运行程序结果如下:
engine = t1
time = 0.015030980110168457
index of 1024 = 1024
engine = t2
time = 0.015980005264282227
index of 1024 = 1024
engine = t3
time = 0.005139946937561035
index of 1024 = 1024
engine = t4
time = 0.0037800073623657227
index of 1024 = 1024
- 有没有觉得很惊讶?打开了 -O 选项后,t4 依然是速度最快的,但是几个方法的性能差距已经不那么明显,而在本例中,t1 和 t2 已经性能相当:
方案 | 不打开优化选项编译 | 打开优化选项编译 |
---|
t1 | 1.05 | 0.015 | t2 | 0.46 | 0.0159 | t3 | 0.15 | 0.005 | t4 | 0.009 | 0.00378 |
- 可以看出,swift 编译器的速度优化表现非常杰出,优化后的 while 循环性能表现已经直逼 memcpy 的速度,因此在如果项目是对性能要求很高的话,一定要打开编译速度优化。但是即便如此,研究高性能的代码方案依旧不能忽视,首先性能优异的代码方案很多不依赖于编译器优化会始终保持出色的性能,其次在不同的需求环境下,可能会选择不同的编译选项,而非始终选择“速度优先”,这时候好的高性能代码设计可以在即使是非速度优先的编译选项下依然有良好的表现。
五、取舍分析
- 大部分时候,开发产品时都不会用到这么大的数据量,因此,在应用编程的场景下,很多时候“数组内置的区间运算符求解”的方案,即 array[begin…<end] 都是优雅又推荐的方案。因为它只需要一行代码,且简单易读,而且在开启了编译优化后,它的表现已经足够让人满意。
- 但是确实存在一些开发场景,性能高度优化的方案,如 memcpy 是有价值的,甚至是至关重要的。包括但不限于:
-
-
-
- Online Judge 平台,如 LeetCode、牛客网以及各大高校 ACM 答题等。
六、总结
- 善用区间运算符和 map:在绝大多数情况下,可以使用以下代码完成子字符串获取,该方法足够的优雅和简短,是应用类开发的不二选择:
array[0..<destCount].map { $0 }
- 用内存复制来提高性能:在性能敏感的领域,充分利用内存复制可以极大的提高性能,但是这种方案往往伴随着风险,开发者必须明确知道自己在做什么,对底层的数据原理需要有清晰的理解,否则很容易产生类似字节数计算错误之类的 BUG:
public func memcpy(_ __dst: UnsafeMutableRawPointer!, _ __src: UnsafeRawPointer!, _ __n: Int) -> UnsafeMutableRawPointer!
- while 循环比 for 循环更快:在 swift 中,由于区间运算符的性能开销,while 循环一般比 for 循环要快不少,在大部分时候这是无关紧要的,但如果发现自己的产品有性能瓶颈,最好检查下是不是 for 循环导致的。
- 注意打开编译器优化开关:为了优化提升,很多时候编译选项需要打开 -O 开关,相对而言这是最佳的选择,会让运行性能大大地提升。
|