读程序员的制胜技笔记08_死磕优化(上)

发布时间 2023-11-10 06:30:30作者: 躺柒

1. 过早的优化是万恶之源

1.1. 著名的计算机科学家高德纳(Donald Knuth)的一句名言

1.2. 原话是:“对于约97%的微小优化点,我们应该忽略它们:过早的优化是万恶之源。而对于剩下的关键的3%,我们则不能放弃优化的机会。”

2. 过早优化是提升自己的根源

2.1. 优化就是解决问题,过早优化创造了暂时没有发现的、假想的问题来解决,就像国际象棋选手设置棋局来挑战自己

2.2. 探索性编程是提高技能的合法途径

3. 不要过早优化的原因

3.1. 优化会增加代码的耦合性,使其更难维护

3.2. 优化也是一项投资,其回报在很大程度上取决于你能将优化结果保持多久

3.3. 如果规范发生变化,你所进行的优化可能会让你陷入一个难以摆脱的困境

3.4. 你可能试图为一个本来就不存在的问题进行优化,而使你的代码变得不那么可靠

4. 解决该解决的问题

4.1. 你需要真正理解你在优化时到底做了什么权衡,这意味着你必须把需要解决的问题了解透彻

4.2. 根据问题的性质,解决方式可以发挥的效用和实现它需要花费的时间可能有很大的不同

4.3. 基准测试(benchmarking)

4.3.1. 比较性能指标的行为

4.3.1.1. 只能给你一堆用于比较的数字

4.3.1.2. 不能告诉你代码的运行速度是快还是慢

4.3.1.3. 可以告诉你它们比其他一些代码运行得慢还是快

4.3.2. 无法帮助你确定造成性能问题的根本原因

4.3.3. 可以帮助你确定是否存在性能问题

4.3.4. 你应该常常对你的那些代码优化进行基准测试,来看看你的优化是否还有更进一步的余地

4.3.5. BenchmarkDotNet库

4.3.5.1. 可以消除因测量误差或者调用开销产生的波动

4.3.5.2. 适用于微观基准测试

4.3.5.2.1. 适用于微观基准测试

4.3.5.3. 基准测试并没有试图消除函数调用的开销或for循环本身的开销

4.3.6. Math.DivRem()函数比普通的除法和求余操作能快多少

4.3.6.1. C#

public class SampleBenchmarkSuite {
  [Params(1000)]  ⇽--- 避免编译器优化
  public int A;
  [Params(35)]  ⇽--- 
  public int B;
  [Benchmark]  ⇽--- 用属性标记要进行基准测试的操作
  public int Manual() {
    int division = A / B;
    int remainder = A % B;
    return division + remainder;  ⇽--- 我们将值返回,这样编译器就不会丢掉计算步骤
  }
  [Benchmark]  ⇽--- 
  public int DivRem() {
    int division = Math.DivRem(A, B, out int remainder);
    return division + remainder;  ⇽--- 
  }
}
using System;
using System.Diagnostics;
using BenchmarkDotNet.Running;
namespace SimpleBenchmarkRunner {
  public class Program {
    public static void Main(string[] args) {
      BenchmarkRunner.Run<SampleBenchmarkSuite>();
    }
  }
}

4.3.6.2. Math.DivRem()的速度是分别进行除法和求余操作的两倍

4.3.6.3. 使用Stopwatch编写自己的基准测试程序

4.3.6.4. C#

private const int iterations = 1_000_000_000;
private static void runBenchmarks() {
  var suite = new SampleBenchmarkSuite {
    A = 1000,
    B = 35
  };
  long manualTime = runBenchmark(() => suite.Manual());
  long divRemTime = runBenchmark(() => suite.DivRem());
    reportResult("Manual", manualTime);
    reportResult("DivRem", divRemTime);
  }
private static long runBenchmark(Func<int> action) {
  var watch = Stopwatch.StartNew();
  for (int n = 0; n < iterations; n++) {
    action();  ⇽--- 我们在这里调用基准测试代码
  }
  watch.Stop();
  return watch.ElapsedMilliseconds;
}
private static void reportResult(string name, long milliseconds) {
  double nanoseconds = milliseconds * 1_000_000;
  Console.WriteLine("{0} = {1}ns / operation",
    name,
    nanoseconds / iterations);
}

4.3.6.5. DivRem函数的运行速度比除法和求余操作快,因为它被转换为需要更少周期的指令

4.4. 性能与响应性

4.4.1. 关于缓慢的一般原则

4.4.1.1. 任何需要超过100毫秒的动作都会让人感觉到延迟,而任何需要超过300毫秒的动作都被认为是缓慢的,更不要说花整整1秒的动作

4.4.2. 性能并不总是与响应性(responsiveness)有关

4.4.3. 任务是计算密集型(computationally intensive)的

4.4.3.1. 最快的计算方法是在工作完成之前不做其他事情

4.4.3.2. 与其以最快的速度进行计算,不如腾出一些计算周期来显示一个进度条,也许可以计算出估计的剩余时间,并在用户等待的时候显示一个漂亮的动画

4.4.3.3. 最后,你的代码运行速度会变慢,但结果会更成功

4.4.4. 延迟也会影响性能,而不仅仅是用户体验

4.4.4.1. 你的数据库驻留在磁盘上,而你的数据库服务器驻留在网络上,这意味着,即使你写了最快的SQL查询,并在你的数据库上定义了最快的索引,你仍然受到物理定律的约束,你不能得到任何快于1毫秒的结果

5. 迟缓的剖析

5.1. 并不是所有的性能问题都是关于速度的

5.1.1. 有些是关于响应性的

5.2. CPU是处理从RAM中读取的指令的芯片,并在一个永无止境的循环中重复执行这些指令

5.3. 时钟周期(clock cycle)

5.3.1. 简称为周期

5.4. CPU速度通常以赫兹(Hz)为单位,表示它在1秒内能处理多少个时钟周期

5.5. 有时CPU甚至可以处理比其处理速度所允许的更多指令

5.5.1. 一些指令需要一个以上的时钟周期来完成

5.5.2. 现代CPU可以在一个核心上并行处理多条指令

5.6. 每一个与代码执行速度有关的性能问题都归结为有多少条指令被执行和被执行多少次

5.7. 当你优化代码时,本质上你要做的是减少指令的执行次数,或者使用更快版本的指令

6. 从头开始

6.1. 直接在源头解决问题

6.1.1. 定位到根本问题

6.2. 减少执行指令数量第二好的方法是选择一个更快的算法

6.3. 最好的方法显然是完全删除代码

6.3.1. 删除你不需要的代码

6.3.2. 不要在代码库中保留不需要的代码

6.3.2.1. 即使不会直接降低代码的性能,也会降低开发人员的“性能”,最终降低代码的性能

6.3.3. 不要保留注释过的代码

6.3.3.1. 可以使用你最喜欢的源代码控制系统(如Git或Mercurial)的历史功能来恢复旧代码

6.4. 让代码运行速度变慢的最简单的方法之一是把它放在另一个循环里

6.4.1. 不应该把计算密集型的代码放在属性的源代码里面

6.4.2. 小心属性

6.4.2.1. 它们包含逻辑,而它们的逻辑并不简单

6.5. 面向字符串的编程

6.5.1. 选择适合的类型会比使用字符串拥有更好的性能

6.5.2. 字符串有一些微妙的方式可以被添加进你的代码里

6.5.3. 一个布尔变量来优化代码

6.5.3.1. C#

if ((string)HttpContext.Items["Bozo"] == "true") {
...
}

6.5.3.2. C#

if ((bool?)HttpContext.Items["Bozo"] == true) {
...
}

6.5.3.3. 节省存储开销和解析开销,还有助于避免你的打字错误,比如把True打成ture

6.5.3.4. 简单的错误对你来说影响不是特别大,但如果错误变成了习惯,影响就会积小成大

6.6. if语句中的布尔表达式是按照它们的书写顺序来评估

6.6.1. 可以简单地调换表达式的位置

6.6.2. 建议根据操作数类型对表达式进行排序

6.6.2.1. 1.变量

6.6.2.2. 2.字段

6.6.2.3. 3.属性

6.6.2.4. 4.方法调用

6.6.3. 逻辑符号是有优先级之说的,以确保你在优化布尔运算时不会意外地破坏if语句中的逻辑