今天我在 libcurl 內部又做了一個小改動[1],使其做更少的 malloc。這一次,泛型鍊錶函數被轉換成更少的 malloc (這才是鍊錶函數應有的方式,真的)。
研究 malloc幾週前我開始研究記憶體分配。這很容易,因為多年前我們 curl 就已經有記憶體調試和日誌記錄系統了。使用 curl 的偵錯版本,並在我的建置目錄中執行此腳本:
#!/bin/sh export CURL_MEMDEBUG=$HOME/tmp/curlmem.log ./src/curl http://localhost ./tests/memanalyze.pl -v $HOME/tmp/curlmem.log
對於 curl 7.53.1,這大約有 115 次記憶體分配。這算多還是少?
記憶體日誌非常基礎。為了讓你有所了解,這是一個範例片段:
MEM getinfo.c:70 free((nil)) MEM getinfo.c:73 free((nil)) MEM url.c:294 free((nil)) MEM url.c:297 strdup(0x559e7150d616) (24) = 0x559e73760f98 MEM url.c:294 free((nil)) MEM url.c:297 strdup(0x559e7150d62e) (22) = 0x559e73760fc8 MEM multi.c:302 calloc(1,480) = 0x559e73760ff8 MEM hash.c:75 malloc(224) = 0x559e737611f8 MEM hash.c:75 malloc(29152) = 0x559e737a2bc8 MEM hash.c:75 malloc(3104) = 0x559e737a9dc8檢查日誌
然後,我對日誌進行了更深入的研究,我意識到在相同的程式碼行做了許多小記憶體分配。我們顯然有一些相當愚蠢的程式碼模式,我們分配一個結構體,然後將該結構添加到鍊錶或哈希,然後該程式碼隨後再添加另一個小結構體,如此這般,而且經常在循環中執行。 (我在這裡說的是我們,不是為了責怪某個人,當然大部分的責任是我自己…)
這兩種分配操作將總是成對地出現,並同時釋放。我決定解決這些問題。做非常小的(小於 32 位元組)的分配也是浪費的,因為非常多的資料將被用於(在 malloc 系統內)追蹤那個微小的記憶體區域。更不用說堆碎片了。
因此,將該雜湊和鍊錶程式碼修復為不使用 malloc 是快速且簡單的方法,對於最簡單的 “curl http://localhost” 傳輸,它可以消除 20% 以上的 malloc。
此時,我根據大小對所有的記憶體分配操作進行排序,並檢查所有最小的分配操作。一個突出的部分是在curl_multi_wait() 中,它是一個典型的在 curl 傳輸主循環中被反覆呼叫的函數。對於大多數典型情況,我將其轉換為使用堆疊[2]。在大量重複的呼叫函數中避免 malloc 是一件好事。
重新計數現在,如上面的腳本所示,同樣的 curl localhost 指令從 curl 7.53.1 的 115 次分配操作下降到 80 個分配操作,而沒有犧牲任何東西。輕鬆地有 26% 的改善。一點也不差!
由於我修改了 curl_multi_wait(),我也想看看它實際上是如何改進一些稍微更高級一些的傳輸。我使用了multi-double.c[3] 範例程式碼,添加了初始化記憶體記錄的調用,讓它使用curl_multi_wait(),並且並行下載了這兩個URL:
http://www.example.com/ http://localhost/512M
第二個檔案是 512 兆位元組的零,第一個檔案是一個 600 位元組的公共 html 頁面。這是 count-malloc.c 程式碼[4]。
首先,我使用 7.53.1 來測試上面的例子,並使用 memanalyze 腳本檢查:
Mallocs: 33901 Reallocs: 5 Callocs: 24 Strdups: 31 Wcsdups: 0 Frees: 33956 Allocations: 33961 Maximum allocated: 160385
好了,所以它總共使用了 160KB 的內存,分配操作次數超過 33900 次。而它下載超過 512 兆位元組的數據,所以它每 15KB 數據有一次 malloc。是好是壞?
回到 git master,現在是 7.54.1-DEV 的版本 - 因為我們不太確定當我們發布下一個版本時會變成哪個版本號。它可能是 7.54.1 或 7.55.0,它還尚未確定。我離題了,我再次運行相同修改的 multi-double.c 範例,再次對記憶體日誌運行 memanalyze,報告來了:
Mallocs: 69 Reallocs: 5 Callocs: 24 Strdups: 31 Wcsdups: 0 Frees: 124 Allocations: 129 Maximum allocated: 153247
我不敢置信地重複看了兩次。發生什麼事了嗎?為了仔細檢查,我最好再運行一次。無論我運行多少次,結果還是一樣的。
33961 vs 129在典型的傳輸中curl_multi_wait() 被呼叫了很多次,並且在傳輸過程中至少要正常進行一次記憶體分配操作,因此刪除那個單一的微小分配操作對計數器有非常大的影響。正常的傳輸也會做一些將資料移入或移出鍊錶和雜湊操作,但是它們現在也大都是無 malloc 的。簡單來說:剩餘的分配操作不會在傳輸循環中執行,所以它們的重要性不大。
先前的 curl 是目前範例分配操作數量的 263 倍。換句話說:新的是舊的分配操作數量的 0.37% 。
另外还有一点好处,新的内存分配量更少,总共减少了 7KB(4.3%)。
malloc 重要吗?在几个 G 内存的时代里,在传输中有几个 malloc 真的对于普通人有显著的区别吗?对 512MB 数据进行的 33832 个额外的 malloc 有什么影响?
为了衡量这些变化的影响,我决定比较 localhost 的 HTTP 传输,看看是否可以看到任何速度差异。localhost 对于这个测试是很好的,因为没有网络速度限制,更快的 curl 下载也越快。服务器端也会相同的快/慢,因为我将使用相同的测试集进行这两个测试。
我相同方式构建了 curl 7.53.1 和 curl 7.54.1-DEV,并运行这个命令:
curl http://localhost/80GB -o /dev/null
下载的 80GB 的数据会尽可能快地写到空设备中。
我获得的确切数字可能不是很有用,因为它将取决于机器中的 CPU、使用的 HTTP 服务器、构建 curl 时的优化级别等,但是相对数字仍然应该是高度相关的。新代码对决旧代码!
7.54.1-DEV 反复地表现出更快 30%!我的早期版本是 2200MB/秒增加到当前版本的超过 2900 MB/秒。
这里的要点当然不是说它很容易在我的机器上使用单一内核以超过 20GB/秒的速度来进行 HTTP 传输,因为实际上很少有用户可以通过 curl 做到这样快速的传输。关键在于 curl 现在每个字节的传输使用更少的 CPU,这将使更多的 CPU 转移到系统的其余部分来执行任何需要做的事情。或者如果设备是便携式设备,那么可以省电。
关于 malloc 的成本:512MB 测试中,我使用旧代码发生了 33832 次或更多的分配。旧代码以大约 2200MB/秒的速率进行 HTTP 传输。这等于每秒 145827 次 malloc - 现在它们被消除了!600 MB/秒的改进意味着每秒钟 curl 中每个减少的 malloc 操作能额外换来多传输 4300 字节。
去掉这些 malloc 难吗?一点也不难,非常简单。然而,有趣的是,在这个旧项目中,仍然有这样的改进空间。我有这个想法已经好几年了,我很高兴我终于花点时间来实现。感谢我们的测试套件,我可以有相当大的信心做这个“激烈的”内部变化,而不会引入太可怕的回归问题。由于我们的 API 很好地隐藏了内部,所以这种变化可以完全不改变任何旧的或新的应用程序……
(是的,我还没在版本中发布该变更,所以这还有风险,我有点后悔我的“这很容易”的声明……)
注意数字curl 的 git 仓库从 7.53.1 到今天已经有 213 个提交。即使我没有别的想法,可能还会有一次或多次的提交,而不仅仅是内存分配对性能的影响。
还有吗?还有其他类似的情况么?
也许。我们不会做很多性能测量或比较,所以谁知道呢,我们也许会做更多的愚蠢事情,我们可以收手并做得更好。有一个事情是我一直想做,但是从来没有做,就是添加所使用的内存/malloc 和 curl 执行速度的每日“监视” ,以便更好地跟踪我们在这些方面不知不觉的回归问题。
补遗,4/23(关于我在 hacker news、Reddit 和其它地方读到的关于这篇文章的评论)
有些人让我再次运行那个 80GB 的下载,给出时间。我运行了三次新代码和旧代码,其运行“中值”如下:
旧代码:
real 0m36.705s user 0m20.176s sys 0m16.072s
新代码:
real 0m29.032s user 0m12.196s sys 0m12.820s
承载这个 80GB 文件的服务器是标准的 Apache 2.4.25,文件存储在 SSD 上,我的机器的 CPU 是 i7 3770K 3.50GHz 。
有些人也提到 alloca() 作为该补丁之一也是个解决方案,但是 alloca() 移植性不够,只能作为一个孤立的解决方案,这意味着如果我们要使用它的话,需要写一堆丑陋的 #ifdef。
以上是優化curl的記憶體分配操作的詳細內容。更多資訊請關注PHP中文網其他相關文章!