前言
公司大佬,经常会在群里发送一些认为比较有价值的技术文章, 之前比较忙没空学习整理, 现在准备学习这些文章,一方面提高自己的见识面, 一方便做个记录方便以后有资料可以查找。
本篇学习的原文来自于这里:Swift子数组提取性能优化分析, 本人通过学习这边文章来整理一个适合自己看的笔记, 同时也通过代码来学习实践下。
问题
数组对于我们编程来说, 是一种常见的数据类型,而从一个数组中获取子数组更是一种较为常见的。那么我们如何优化这个获取子数组的过程,来解决一些性能上的问题。
首先我们假设在开发过程中,需要一个非常大的数组来存放数据, 数组的元素是基础类型,例如Int ,有时候需要从这个大数组中获取同样不小的子数组,并且获取结果数组。
我们定义数组的名称为array ,数据赋值20000000 ,这个规模的数据已经足够用来测试性能。
首先我们需要创建一个swift 的命令行项目:
let arrayCoount = 20000000
var array:[Int] = Array(repeating: 0, count: arrayCoount);
for i in 0..<array.count {
array[i] = i
}
接下来我们定义一个数组变量testArray 用来接收数据,我们用testArray 来接收array 的子数组,要求它获取array 的前一半的内容,也就是10000000(一千万) 个数据元素。
let testCount = 10000000
var testArray:[Int] = []
接下来我们按照要求来写代码给testArray 赋值
1. for方法求解
这种方法非常的简单, 只要有代码基础的都知道怎么用。
let testCount = 10000000
var testArray:[Int] = []
let t1 = Date()
testArray = Array(repeating: 0, count: testCount)
for i in 0..<testArray.count {
testArray[i] = i
}
let t2 = Date()
print("time===\(t2.timeIntervalSince(t1))")
print("index====\(testArray[1024])")
这段代码非常的简单, 为了测试代码的性能,我们通过计算前后时间的一个差值,来得出这段程序运行的时间,最后在数字中随便取一个值,表示数组确实已经被赋值, 在本人的机器上打印结果: 打印结果显示,在本机环境下,处理需要大约7秒
测试的读秒在不同配置的机器上结果是有差异的。所以算法时间比较不应以不同机器间的差异为准,而应以同一台机器不同算法之间的时间比例差距为准。
2. 数组内置的区间运算符求解
事实上swift中的数组提供了一个非常方便的内置函数,该函数的声明如下:
@inlinable public subscript(bounds: Range<Int>) -> ArraySlice<Element>
该函数的功能正是提供一个数组的子数组,完全符合我们的要求 该函数是一个区间运算符函数, 它的效果和函数的调用效果完全一样,只是调用方法上是通过[a..<b] 来完成的
另外该函数返回的不是数组类型, 而是一个被称为ArraySlice 类型,该类型描述的是原数组中一个区间数据,这样就避免了计算时直接拷贝出一个数组的性能消耗,因为调用者可能并不需要获取拷贝,只想拿到区间。
而我们当前的需求是拿到一个数组拷贝,所以需要补充额外的代码,代码如下:
let t1 = Date()
testArray = array[0..<testCount].map({ $0 })
let t2 = Date()
print("time===\(t2.timeIntervalSince(t1))")
print("index====\(testArray[1024])")
第二个方法中我们利用array[0..<testCount] 获取子数组的区间类型,在利用map 方法生成新的数组,数组的元素正是数值元素的值Int ,所以直接使用内置变量$0 即可完成操作,运行代码,观察结果
由打印结果可知,方法2比方法1快了3.5倍,我们的性能向前迈进了一大步,而且方法二还有一个很大的优点,我们不需要写一行来创建testArray 数组,一行代码我们就解决了。
关于map { $0 }这段代码,是swift特有的语法糖,可以通过swift的语言指导文档了解它的特性。事实上它就是一个普通的名字为map的函数,该函数接收一个回调函数作为参数,这个参数我们通过{ $0 }提供了,该回调函数会提供原数组(或者迭代器)的每一个元素作为参数,然后要求你返回一个值,你返回的值会作为map返回的数组的元素值。而$0正是代表着迭代的每个元素,因为我们要返回的正是该元素的类型,所以直接返回即可。又因为swift的语法机制规定当我们单独提供一个语句时,该语句可以作为返回值,所以又省去了return语法。最后就是你看到的{ $0 } 。如果你看到这里还是不太清楚,也不要紧,并不妨碍本文的主题。请保持好奇心继续前进。
3.方法三:while循环
我们考察前面两个方法时,很容易发现了他们都用了区间运算begin..<end ,事实上区间运算好用归好用,但是性能理不理想是另外一回事情,我们这就测试下,如果在不适用区间运算,那么性能会如何?因为第二种方法肯定是需要区间运算的, 第一种方法用的是for 循环,我们改成while ,看看结果会怎么样?
let t1 = Date()
testArray = Array(repeating: 0, count: testCount)
var i = 0
while i < testCount {
testArray[i] = i
i += 1
}
let t2 = Date()
print("time===\(t2.timeIntervalSince(t1))")
print("index====\(testArray[1024])")
这个简单的while 循环,让我们看下结果:
这个运行时间大概是0.4 秒,比第二种方法快了接近4倍,比第一种方法快了接近14倍,从这里我们可以得出结,论,在swift性能敏感的领域,while 比for 更可靠。
4.内存复制
在应用开发上,设计导数据之间的拷贝,直接的内存拷贝在性能上总是拔群的,因为它省去了中间计算和转换的过程,直接一比一的把一块的内存数据赋值给另外一块内存。
这个道理在某些语言上不一定行的通,应为某些语言并不对外规定元数据在内存中的字节序列是如何存放的,如果这一层被屏蔽了,那么内存赋值就无从谈起。
幸运的swift 提供了基础数据在内存中的映射关系,如对[Int] 类型它的内存就是按照连续的低位到高位的Int 存放的,而每个Int 都占据固定的字节数(针对64位机编译的结果是64位,针对32位机编译的结果是32位 )
既然知道了这个原理,我们需要把一个整形数组,(或者其中的一部分连续空间)的值赋值给另外一个数组,直接进行内存赋值就可以了。
由于swift 可以调用C的标准函数库,那么我们可以直接使用memcy 这个内存拷贝函数来解决问题就可以了
let t1 = Date()
testArray = Array(repeating: 0, count: testCount)
memcpy(&testArray, &array, testCount*MemoryLayout<Int>.size)
let t2 = Date()
print("time===\(t2.timeIntervalSince(t1))")
print("index====\(testArray[1024])")
这个代码更加简单名了,然而编码方面要求更加仔细。
首先memcpy 要求提供的前两个参数分别是目标数据的地址 和源数据的指针 ,我们可以通过&运算符获取。最后一个参数要求提供复制的字节数。注意,destCount仅仅是数组的元素个数,并不是字节数!为了计算字节数,我们应该通过如下计算:字节数 = 单个数组元素的字节数 * 数组元素个数
所以第三个参数应该传递testArray 乘以Int 字节数。 所以运行结构如下:
运行时间0.04 秒,比起第三种方法还要快, 内存拷贝方法确实是最快的方法,但是也是同时从编码的角度讲风险更大的方法,请注意在性能和编码清晰度之间进行取舍
以下是综合了四种方案选择的示例代码:
let arrayCoount = 20000000
var array:[Int] = Array(repeating: 0, count: arrayCoount);
for i in 0..<array.count {
array[i] = i
}
for method in ["t1","t2","t3","t4"] {
let testCount = 10000000
var testArray:[Int] = []
print("method========\(method)")
let t1 = Date()
if method == "t1" {
testArray = Array(repeating: 0, count: testCount)
for i in 0..<testArray.count {
testArray[i] = i
}
} else if method == "t2" {
testArray = array[0..<testCount].map({ $0 })
} else if method == "t3" {
testArray = Array(repeating: 0, count: testCount)
var i = 0
while i < testCount {
testArray[i] = i
i += 1
}
} else if method == "t4" {
testArray = Array(repeating: 0, count: testCount)
memcpy(&testArray, &array, testCount*MemoryLayout<Int>.size)
}
let t2 = Date()
print("time===\(t2.timeIntervalSince(t1))")
print("index====\(testArray[1024])")
}
现在可以一下子比较四种方法的差异, 打印结果如下:
编译器优化
到目前为止,我们已经比较了四种方法去除数组个数为1000万 时的性能差异,但是这真的就是标准答案吗? 其实不尽然,因为swift编写的代码毕竟不是机器码,根据不同的编译器选择,他们编译生成的最终码也不相同,这里面自然会有很细微的差异,那么差异会多大了。
让我们来做一个实验,swift 编译器带有一个专门的优化速度的编译选项(当然了,代价是增加编译时间,毕竟世上没有白吃的午餐)
编译选项选择速度优化,可以在xcode 中可以通过点击 工程栏→Target→Swift Compiler - Code Generation→Optimization Level 选择Optimize for Speed 。 如果你不是在mac 环境下,没有用xcode 而是用swiftc 来编译程序呢?那更简单了,直接运行命令swiftc -O 文件名.swift 即可。
打开这个优化选项之后,运行结果如下:
打开-O 选项后,t4 依然是最快速度的,但是几个方法的性能差距已经没那么明显了,而在本例中t1 和t2 的性能已经相当
可以看出,swift 编译器的速度优化表现非常杰出,优化后的while 循环性能表现已经直逼memcpy 的速度。所以在如果项目是对性能要求很高的话,一定要打开编译速度优化。但是即便如此,研究高性能的代码方案依旧不能忽视,首先性能优异的代码方案很多不依赖于编译器优化会始终保持出色的性能 ,其次在不同的需求环境下,我们可能会选择不同的编译选项,而非始终选择“速度优先” ,这时候好的高性能代码设计可以在即使是非速度优先的编译选项下依然有良好的表现。
取舍分析
大部分的时候,我们开发产品时不会用到这么大的数据量
所以,在应用编程的场景下,很多时候第二种方案,即array[begin..<end] 都是优雅又推荐的方案,因为它只需要一行代码,且简单易读。而且在开启了编译优化后,它的表现已足够让人满意。
但是确实存在一些开发场景,性能高度优化的方案 - 如memcy 是有价值的,甚至是至关重要的
- 包括单不限于:
音视频、图片 中,大块字节序列的处理和获取。游戏 大容量资源选取,加载和提取。Online Judge平台 ,如LeetCode ,牛客网 以及各大高校ACM答题 等。
经验总结:
善用区间运算符和map : 在绝大多数的情况下,请使用以下代码完成子数组的获取(array[0..<testCount].map { $0 } ),因为这个方法足够的优雅,足够的简短,是应用开发类的不二选择。用内存复制来提高性能 :在性能敏感的领域,充分利用内存复制可以极大的提高性能,但是这种方案往往伴随着风险,开发者必须明确知道自己在做什么,对底层的数据原理需要有清晰的理解,否则很容易产生类似字节数计算错误之类的BUG
public func memcpy(_ __dst: UnsafeMutableRawPointer!, _ __src: UnsafeRawPointer!, _ __n: Int) -> UnsafeMutableRawPointer!
-
while 循环比for 循环更快 在swift 中,由于区间运算符的性能开销,while 循环一般比for 循环要快不少。在大部分时候这是无关紧要的,但如果发现自己的产品有性能瓶颈,好好检查下是不是for 循环导致的吧。 -
注意打开编译器优化开关 为了优化起见,大部分时候编译选项打开-O 开关一般而言总是最佳选择。这会让运行性能大大提升。
|