65.9K
CodeProject 正在变化。 阅读更多。
Home

万恶之源是优化?还是冷漠?

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2013年6月15日

CPOL

12分钟阅读

viewsIcon

10241

万恶之源是优化……或者冷漠

你可能听过这样一句话:“过早优化是万恶之源。”

那么,结果如何呢?每台Windows电脑的系统托盘里都塞满了各种小工具。用户可能不知道那些小图标是什么,但每一个似乎都会让电脑在启动时多花费10秒钟,并多占用10MB的内存。此外,还有许多用户看不到也无法测量的隐藏服务,但它们会像系统托盘图标一样拖慢电脑的速度。当然,有些应用程序小巧、优化得当且必不可少,但少数“害群之马”(用户根本无法定位)却毁了启动体验。

我有一个不同的看法:我认为,优化是许多程序员的主要工作——并非所有程序员都如此,但许多程序员是。看,这里有一个算法,用于在一个点列表中查找最近的点。

  using System.Windows;
  ...
  public int ClosestPointTo(Point near, IList<Point> list)
  {
    return list.IndexOfMin(p => (p - near).Length);
  }

好了,这很容易!那我怎么花了好几天研究并编写一个R*树实现呢?因为简单的解决方案实在太他妈慢了!任何人都能找到问题的“朴素”解决方案,如果我们只需要这个就够了,那么找“足够好”的程序员也很容易。但不可避免地,随着问题规模的增大,朴素的解决方案就不够用了。当问题规模变得非常大(对某些人来说迟早会如此)时,即使是所有人认为不错的解决方案也会变得毫无用处。

我承认,我有一个过早优化的坏习惯,它确实是一种恶习,有时。例如,我为我的公司开发了一个名为FastNav的逐向导航软件,它需要使用一种专有格式的“NaviMap”文件,这些文件是从Shapefile转换而来的。我曾认为用文本文件“脚本化”转换过程会很棒,使用ANTLR解析表达式然后解释执行,但我担心解释动态类型表达式会很慢。所以我花了很多时间编写了一个小编译器,将这些文本表达式转换为静态类型、已编译的MSIL,类型根据Shapefile的模式确定。

所以现在我有一个超快的表达式求值器。但猜猜怎么着?我的MapConverter至今仍然很慢,因为事实证明瓶颈在别处(我改进了最严重的瓶颈,但仍然存在其他难以修复的瓶颈。用户并没有抱怨,所以我随它去了)。

但FastNav本身也太慢了,运行在400MHz的WinCE设备上,我花了六个月的时间进行优化,直到我的老板满意为止(而且这还是从头开始编写所有绘图基元之后,因为WinCE的绘图代码太糟糕了,而AGG虽然比WinCE本身快,但仍然太慢)。WinCE没有好的性能分析器,而且随着时间的推移,我形成了一种直觉,即WinCE代码的瓶颈与桌面上的瓶颈截然不同(实际上,在桌面版本上,几乎没有什么瓶颈);以至于桌面上的性能分析结果完全没有用。所以我费尽心力地测试了闪存I/O性能(非常慢)、浮点运算(非常慢)、字体绘制(如果文本旋转则非常慢,否则很快),以及FastNav的各种模块,逐一优化,直到产品最终可用。我甚至优化了std::vector(编写了一个名为inline_vector的替代品,然后发现这并没有太大帮助,又编写了一个更简单的替代品mini_vector),实现了自己的哈希表,并替换了内存管理器(new和delete)以优化小内存分配。

优化一直是我工作中重要且必要的一部分。大约在1998年,我用C++编写了(现在已死的)Super Nintendo模拟器SNEqr,但有人告诉我C语言的优化器“现在真的很棒”,没必要再使用汇编语言了。好吧,我太傻了,居然相信他们,用C语言编写了CPU模拟器——它运行得慢得惊人,当我用汇编语言重写它时,速度提升了大约10倍。我写的每个程序似乎都会遇到运行太慢的情况,要么需要优化——要么用户就只能忍受。

经历了所有这些之后,我倾向于尽早优化,这可能是一个坏习惯,因为我可能会选择优化错误的东西——非瓶颈。

但我确信,过早优化远不如他妈的毫不在乎那么糟糕,而这是一种更常见的做法。例如,为什么几乎每个程序都需要一个启动画面,并在空闲系统上花费几秒钟才能启动?我从未写过一个启动需要超过一两秒钟的程序——除非是由于庞大而缓慢的第三方组件。

我最近为出租车调度员开发了一个名为IntelliMap的应用程序。在我快速的开发机器上,它至少需要6秒钟才能在启动一次后重新启动。但这是WPF版本。我最初是用WinForms写的,那个版本瞬间重新启动,就像记事本一样。但我被告知用户界面看起来“太90年代了”,应该使用WPF和Infragistics控件来现代化。幸运的是,我使用了MVVM模式,能够在新视图代码中使用相同的模型和视图模型。事实上,我在同一个可执行文件中保持了WinForms版本的运行。至今,你可以使用"--winforms"开关启动它,它会立即启动,尽管界面是“90年代风格”的,而且功能较少,因为我没费心维护WinForms版本。

问题比听起来更严重。因为虽然在我快速的开发机器上重启可能需要6到7秒,但在我们一个客户的机器上,重启(非冷启动)需要超过45秒。而且这不仅仅是启动时间;WPF UI更卡顿,占用的内存也更多。

这真的让我非常恼火。我编写了一个启动即时的程序。但后来我不得不使用那些极其缓慢的二方和三方库。“过早优化”的论点是,你应该等到优化;等到你的应用程序变慢了,然后分析代码以找出并消除瓶颈。但有三个问题:

  1. 如果你在自己快速的开发机器上等待它变慢,你就等太久了。你的一些最终用户将拥有更老、更慢的硬件。
  2. 如果你没有良好的编程习惯,那么你的代码将一直很慢。所以你不是只有一个或两个瓶颈需要优化,而是有很多;你修复的每个瓶颈只会使下一个瓶颈更加明显。如果你有编写快速代码的习惯,瓶颈会更少,你花费在优化上的精力也会更少(除非操作系统本身很慢,我说的就是WinCE)。
  3. 这个论点很难应用于库。显然,微软和Infragistics认为他们的WPF控件对他们来说已经足够快速和精简了。但对我来说还不够快!编写一个供他人依赖的底层库时,开发者等到它对他自己来说太慢是没用的。库会被许多人用在许多场景中。在一个应用程序中不是瓶颈的库代码,在另一些应用程序中肯定会成为瓶颈。每个应用程序都会以某种方式压榨底层库,但每个应用程序都会在不同的地方造成压力。这意味着核心的、常用的库应该被统一优化。你可能会说“好吧,即便如此,也只有内层循环需要优化。”但许多非循环代码也需要优化,以防客户端应用程序在重要的循环中调用这些非循环代码。

在我看来,代码越底层,其速度就越重要。我写了很多底层代码,所以速度对我来说几乎总是重要的。依赖别人编写的慢速库,尤其是那些我甚至无法理解的闭源商业产品,更不用说对其做任何事情了,这非常令人恼火。

对于被许多不同的人使用的代码,优化其公共接口系统架构可能是值得的,即使对其实现进行优化并非如此。我手头没有很好的例子,但考虑一下IEnumerator接口:你必须在每次迭代中调用MoveNext(),然后是Current——两次接口调用。由于这是一个基本、无处不在的接口,被所有人频繁使用,并且经常在紧密循环中使用,如果它能通过一次接口调用就迭代并返回下一个项,那就好了。

bool MoveNext(out T current)

然而,由于它是一个public API,它不能被更改;这就是为什么public接口需要事先仔细设计。(当然,性能不是API设计的唯一因素;灵活性和易用性等因素同样重要。)

你不必孤军奋战

有些人似乎认为语言只有两种:高效且让开发者更辛苦的语言,如C/C++,以及易于使用且富有成效的“RAD”语言,如Ruby或C#,但在运行时有速度限制。有些人认为你无法在一门语言中同时拥有运行时性能和开发效率,我坚决反对这一观点。这种语言可以存在,应该存在,甚至在很大程度上已经存在(例如D2)。

反对过早优化的论点是,如果你优化错了地方,就会浪费时间,或者它会使代码难以理解,或者你可能会犯错,将正确代码变成错误代码。但如果它很简单,并且不损害可读性呢?还有什么理由不优化呢?

这种优化——简单且不损害可读性的优化——的一个明显例子是,当你去编译器设置中启用优化时。啊,一天的工作就完成了!但优化器无法做到一切,因为你拥有它所不具备的知识,它可能永远不够聪明,无法将你对排序列表的线性搜索转换为二分搜索。

长远来看,解决方案的一个重要部分是让编译器获得更多知识,例如使用具有“效果”和“全局优化”等功能的编程语言。另一个重要部分是拥有标准库,这些库不仅有许多快速算法可供调用,而且使这些算法易于查找和使用。但程序员自己也可以在自己的代码中使用一些简单易行的优化,这只需要对我们的编程语言进行一些调整。例如,假设你想通过某种数据结构运行某种搜索,并且仅当结果多于一个时才扫描结果。写起来很容易

  if (DoSearch().Results.Count > 1)
     foreach(var r in DoSearch().Results) {
        // do something with r
     }

现在,如果我们像这样重构它

  var rs = DoSearch().Results;
  if (rs.Count > 1)
     foreach(var r in rs) {
        // do something with r
     }

那是我们永远回不来的20秒。或者可能超过20秒,如果这是链式“else if”子句的一部分,因为你必须花一些时间思考如何重构它。

  if (...) {
     ...
  } else if (DoSearch().Results.Count > 1) {
     foreach(var r in DoSearch().Results) {
        // do something with r
     }
  } else if (...) {
     ...
  }

而且,重构现在让代码更长了。那么我们应该费心吗?这难道不是我们被警告过的过早优化吗?但是,如果数据结构很大呢?我们不应该搜索两次,对吧?

我認為這個兩難境地不應該存在;這完全是程式語言的缺陷。這就是為什麼我為EC#定義了“快速綁定”運算符

  if (DoSearch().Results=:rs.Count > 1)
     foreach(var r in rs) {
        // do something with r
     }

它乍一看可能很奇怪(它是Go语言中 := 短變量聲明運算符的反向,我正在考慮是否用“::”來代替這個運算符),但現在沒有兩難境地了。創建一個新變量來保存DoSearch().Results的值很容易,所以你乾脆這麼做,然後繼續前進。無需權衡“過早優化”的利弊。

另一個很好的例子是LINQ。假設我們有一個長長的數字列表,我們想從中派生出另一個數字列表。查詢內容是什麼不重要,這是一個範例

List<int> numbers = ... // get a list somehow
var numbers2 = (from n in numbers where n < 100000 select n + 1).ToList();
但LINQ涉及一系列委託方法,由於.NET的設計方式,這些方法無法內聯。你可以通過這樣的循環獲得更高的速度

for (int trial = 0; trial < 200; trial++)
{
  numbers2 = new List<int>();
  for (int i = 0; i < numbers.Count; i++) {
    int n = numbers[i];
    if (n < 100000)
      numbers2.Add(n + 1);
  }
}

我在我的PC上對此進行了基準測試(200次試驗,1000000個最多6位數的隨機數),結果如下:

LINQ:    2500ms (100579 results)
for:     859ms (100579 results)
foreach: 1703ms (100579 results)

因此,普通的for循環快了將近3倍(令人驚訝的是,foreach版本(未顯示)只快了一點點)。

那麼,你應該改用普通的for循環來獲得額外的速度嗎?通常,答案是“當然不是”。但我的答案是“當然不是——你的電腦應該改用普通的for循環。”

這就是EC#將擁有過程宏系統的眾多原因之一,以便最終用戶可以自行優化代碼,使用宏來檢測某些代碼模式並將它們重寫為更高效的形式。當然,最常見的優化可以打包到DLL中共享,所以大多數人不會自己編寫這些轉換。通常,用戶只需編寫一個全局屬性,如[assembly: LinqToForLoop]來將宏安裝到他們的程序中,或者他們可以將屬性附加到類或方法上以進行更保守的優化。

我還沒有真正弄清楚LinqToForLoop宏代碼會是什麼樣。我目前的想法是,這種查找代碼模式並重寫它的宏應該“註冊”它想要查看的節點類型。編譯器將代表宏查找這些節點,並在找到時將它們傳遞給宏。這將比將“整個程序”傳遞給宏並讓宏自己尋找東西的明顯解決方案更有效。由於程序員不可避免地會使用大量宏,每個宏單獨掃描程序將非常低效。

<發布於>
© . All rights reserved.