寫了多年軟件,我在軟件性能上學到的 4 點教訓

寫了多年軟件,我在軟件性能上學到的 4 點教訓

英文原文: https://blog.nelhage.com/post/reflections-on-performance/

在職業生涯中,我至少參加了三個對軟件性能表現有一定要求的項目,它們分別是 Livegrep 、 Taktician 和 Sorbet 。此外,我還對正使用的工具做了許多提升性能的工作。

一、性能是軟件的一個重要特性

我很贊同這樣一個觀點,即軟件性能不是獨立於軟件功能或軟件特性集合的一個屬性。性能(尤其是指能顯著提升速度的性能)本身就是軟件的一項功能,它從根本上改變了一個軟件工具的使用和感知方式。

在推出 Sorbet 後,我們從 Stripe 工程師那裡得到大量反饋和讚賞,因為這個軟件工具的性能非常優越。

與之前緩慢的工具軟件使用體驗相比,開發人員真正體會到用高性能軟件帶來的快感(例如,對 Stripe 的代碼庫進行類型檢查,Sorbet 從冷啟動加載都會比 Ruby 正常加載要快,更不用說執行其他代碼,在我看來,這是 Ruby 生態不太好的地方)。

我認為性能的價值在普遍意義上非常容易理解——許多工程師都知道並經常討論響應時間感知閾值或 延遲對轉換率的影響——但能真正理解性能內在含義的人實際上很少,大部分只是紙上談兵。最近感覺抱怨軟件運行速度緩慢的人很多,但是也很少有團隊可以為此做些什麼,以至於工具的性能變得越來越慢。

從業經驗告訴我,儘管我們的工具讓編寫高性能軟件變得越來越難,但產出高性能軟件不是沒有可能,而且這一努力是值得的。

二、性能改變了用戶使用軟件的方式

毋庸置疑,用戶更偏愛性能好、速度快的應用軟件,因為與速度慢的軟件相比,這會帶來更好的用戶體驗。

高性能軟件改變了用戶使用軟件的方式 ,這一點也許體現的不是很明顯。用戶通常會使用多種策略來實現目標任務,並且他們將會越來越頻繁地選擇使用更快的工具。性能更好的工具不僅可以幫助用戶更快地完成任務,而且還能讓用戶以全新方式完成各種類型的任務。

在做Sorbet 和Livegrep 工作時,我清楚地認識到這一點:

Sorbet 項目的主要目標之一是在開發過程中,為用戶輸入的代碼提供快速反饋。Stripe 始終維護著一個擴展測試套件,該測試套件有較高的質量以及代碼覆蓋率,並且其運行時間始終維持在 10-15 分鐘內。我們希望 Sorbet 可以減少一些軟件測試和運行時錯誤,但是縮小測試用例或在生產環境中獲得額外的安全性並不是我們的主要目標。相反,我們希望獲得的最大收益是縮小開發過程的反饋循環,並使用戶對其代碼的操作反饋比以前更快。

在開發環境中,Stripe 的許多測試會執行 10-20 秒甚至更多。因為我們成功構建了 Sorbet,所以可以在那個時間窗口內對整個代碼庫進行類型檢查,這讓開發人員能針對自己的代碼獲得合理的反饋,快速的檢查基本輸入問題、API 的不合理使用問題以及其他低級錯誤。這是他們為快速開發所做的選擇,我們經常看到用戶在產品開發和上線早期就選擇 Sorbet 作為他們代碼檢查的工具。及時獲得代碼檢查的反饋,這可以增加開發人員的信心,從而節省大量時間,這一點非常重要。

快也意味著用戶對誤判有一定的容忍度(Sorbet 識別出的錯誤代碼可能實際情況並不會出錯),前提是軟件知道如何解決錯誤(比如增加 T.must 聲明)。如果 Sorbet 與 CI 的運行時間運行相近,比如 10 分鐘左右,那麼用戶與 Sorbet 的關係將會是另一番樣子。它必須在 CI 的基礎上提供非常明顯且有差異的附加價值,而且儘量不會為了兼顧 Sorbet 而要求用戶進行更改,因為這個時候它不再具有向用戶提供早期反饋的優勢。

觀察用戶使用 livegrep 的情況,我發現性能提升的另一個好處。Livegrep 的目標是在 100 毫秒內響應大多數搜索, 100 毫秒這個閾值非空穴來風,它有據可查的,在此閾值內,大多數用戶會感覺到響應幾乎是瞬時的,沒有什麼滯後之感。由於 livegrep 是如此之快,用戶能以一種交互的方式來使用它,這種交互方式在其他搜索引擎上幾乎很少見到:用戶輸入一個初始查詢,緊接著,如果得到大量或少量的搜索結果,用戶可以根據結果列表進行再編輯,然後獲得一組新的結果,這會進一步完善或擴展搜索條件,直到找到用戶真正要查詢的結果。

我創建 livegrep 來用在正則表達式上,這在很大程度上是一個有趣的技術挑戰——理論上,對於 Livegrep 的許多使用場景來說,語法或語義感知搜索聽起來更好一些。但是,我從未見過像 livegrep 一樣快的語法感知工具,而且我逐漸體會到 livegrep 的交互功能引出了它額外的功能,因為它使迭代和優化搜索變得容易。而且,大多數工程師已經對正則表達式有所瞭解,並且能以交互方式進行實驗,實時瀏覽和查看結果,相對於使用複雜的查詢語法來實現搜索,livegrep 顯得更加用戶友好,方便使用。

寫了多年軟件,我在軟件性能上學到的 4 點教訓

三、性能需要貫穿項目整個生命週期

在項目的整個生命週期中,追求性能需要付出一定的努力。如今, 不用擔心性能問題這個觀點越來越流行,尤其是在項目初期。我們經常聽到這些論調:

  • “過早的優化是萬惡之源”
  • “先運行,再糾正,後調優”
  • “CPU 的時間總是比工程師的時間廉價”

我們會從那些認為 Ruby 或 Python“ 足夠快”的人那裡聽到類似的觀點。這個觀點要表達的似乎是性能僅僅是最後才要考慮的事情,有效的代碼才最關鍵。

我瞭解到的普遍流行的哲學觀點似乎是,首先應該以最快的方式編寫出應用程序,只有在程序正常工作後,再轉向利用分析器並開始逐個優化熱點代碼,甚至最後可能將某個組件使用更快的編程語言或技術進行重構。

這確實是一個不錯的並被普遍默認接受的建議,但也瞭解到,認識到這些觀點的侷限性,並在項目開始時能夠尋求其他的實現形式確實很重要。特別是,我開始相信“功能優先,性能讓步”模型很少會產出真正高性能的軟件(並且如上所述,我認為真正快速的軟件是一個值得追求的目標)。我總結了上面的方法論無效的兩個主要原因:

1. 軟件架構會直接影響性能

系統的基本架構(高級結構、數據流和組織形式)通常會對性能產生長遠影響。正如之前討論的那樣,我們在Sorbet 中所採取的一個設計決策是僅在方法內執行局部類型推斷。這一決定對類型系統和用戶體驗產生了影響,但同時也使Sorbet 大大簡化、更快並且更易於並行化和增量擴展。

提前進行架構設計,後續的維護成本會降低,否則,後期如有修改,成本陡增。Flow 團隊仍處在持續多年的重構計劃當中,他們的目標是將Flow 和Facebook 的代碼遷移到本地化模型中(我想明確一點,我並不是在批評Flow 團隊的選擇。他們的工具在Facebook 內部和外部都非常成功,而且他們做出的決定在當時來看是沒有問題的。他們在博客中討論過這個問題,我很欣賞他們能對外公開透明。不過,我發現這個案例在一定程度上可以說明早期架構決策會對現在產生一定的影響)。

如果你要構建真正的高性能軟件,那麼在制定早期設計和體系架構決策時需要牢記性能因素影響,以免日後陷入非常尷尬的境地。

2. 優化性能不能只關注熱點代碼

像大多數編譯器和類型檢查器一樣,Sorbet 也沒有什麼熱點代碼。CPU 時間在軟件代碼的不同功能部分被相對均勻的分配和執行。這種均勻分散執行從根本上意味著,不可能對一個檢查器通過優化熱點代碼的形式來提升性能,因為幾乎沒有熱點代碼可以進一步優化。

相反,我在上一篇文章中討論的許多技術,例如緩存優化的數據結構或用 C ++ 重構實現關鍵模塊,實質上會使類型檢查器中的每一行代碼都執行得更快,從而使整個應用的性能得到提升。

我最喜歡的性能提升案例之一,就是 SQLite 3.8.7 版本比以前的版本性能提升 50%,所有這些都是通過大量累加的微小性能改進而實現的,其中每個優化獲得的性能增加不到 1%。這個例子提到,每個優化對整體代碼的優化提升微不足道,即使是這樣,性能優化隨著微小性能改進的累積可以逐步放大。儘管,最後 SQLite 開發人員是在產品成熟之際進行了這個優化工作,但是,如果能在產品早期進行 1% 的優化,後續的優化工作也會簡單很多。

四、從性能的基礎出發能簡化架構

我發現的另一個觀點是,相對於從指定的功能級別開始優化,從內核開始高性能設計可以最終極大地簡化軟件項目的體系架構

在我們編寫 Sorbet 之前,Stripe 進行了一些內部 Ruby 代碼的靜態分析,該分析是 Sorbet 整體分析(全局常量解析)有限的一小部分。這些代碼分析由 Ruby 實現,構建在 Parser gem 之上,性能比 Sorbet 明顯慢得多,並且由於 Ruby 的 GVL 限制,使得並行化非常有很大的挑戰性。為解決並行化問題,我們最終使用基於fork的並行化構建了一個日趨複雜的緩存解決方案,只是為了保持運行時間在可接受的範圍內。由於大大提高基準性能以及簡化了系統內部並行性,Sorbet 能夠完勝那寫沒有使用緩存策略的系統。

有一個普遍的現象是:嘗試為速度較慢的系統提升性能通常會增加系統複雜性,例如複雜的緩存系統、分佈式系統或為進行詳細的增量計算而設計的明細記錄系統。這些功能都會增加複雜性,可能引入新類型的 bug,還會增加額外的開銷成本,系統性能會下降甚至更糟,從而使問題進一步加劇。

如果從一開始軟件性能就夠快,可能根本不需要這些附加中間層就可獲得可接受的總體性能,而且相同的性能水平下,軟件系統架構也會簡單很多。

以 Sorbet 為例,雖然我們確實使用了一些緩存策略,而且 Sorbet 的 LSP 服務在執行增量重新類型檢查時有些複雜,但是與我所熟悉的使用同類類型檢查器的系統相比,我們的系統還是要簡單很多。我們能獲得這種簡單性在很大程度上是因為 Soebet 的基本性能本身就不錯;Sorbet 不必費時費力地保存工作中間結果,因為它通常可以很快地就能完成工作。

Sorbet 一個非常好的特性是它的緩存格式不存在向前或向後兼容的問題:Sorbet 的發行版本可以獲取自己版本 git commit 的 sha1 值,並且會忽略其他不同發行版本生成的緩存文件。這一決定意味著緩存格式可以非常簡單和輕量級,並且我們不必擔心數據遷移或者兼容性問題。當然,缺點就是每次我們在發佈一個版本時,因為啟用時暫未加載使用緩存數據,用戶會短暫地感覺到軟件性能有所降低,這一現象會在新的緩存加載成功後消失。由於緩存僅僅保存幾秒鐘,並且軟件冷啟動時間仍小於 20 秒,因此我們認為這是一個可以接受的折中方案,因為它帶來了簡單性的同時也降低了 bug 發生概率。

相比之下,我有幾個朋友在 Dropbox 公司從事 MyPy 的部署工作,它們在未加載緩存啟動運行系統時,可能需要十分鐘甚至更長的時間。這種巨大的性能差異意味著他們使用著完全不同的算法設計,並且不得不去更加擔心何時以及當前是否可以使緩存失效,這就增加了性能維護的複雜性,而這些我們都能完全避免。

隨著 Stripe 的 monorepo 的持續增長,僅僅依賴 Sorbet 的原始性能是遠遠不夠的,我希望 Sorbet 最終會支持更加複雜的緩存以及增量執行;原始性能強並不是萬能的。但是,一定要在給定規模水平下討論這個問題,具體問題具體分析,這一點很重要。通過基本簡單地設計就可以達到像 Sorbet 這樣的性能水平,所以我們不要低估這種基本性能設計所帶來的收益。

總結

我相信,我們在設計和構建軟件時常常會低估性能帶來的影響。在軟件設計時,我們已經習慣於遵從工具或者標準庫的固定選擇,而忽略一些因素對軟件性能的影響,也不會考慮這麼做是否值得。

到 2020 年,Sorbet 軟件仍然可以運行的很流暢,響應也很快,因此我們之前付出的努力都是值得的。你印象中有哪些軟件,因為其優越的性能讓你感到震驚,並大大提高了你的生產力?你能說出幾個嗎?

關注我並轉發此篇文章,私信我“領取資料”,即可免費獲得InfoQ價值4999元迷你書!


分享到:


相關文章: