1. 背景
排序服務(wù)新上線一個(gè)DPP(Determinantal Point Process)重排算法邏輯,基于DJL(Deep Java Library)實(shí)現(xiàn)的, DJL目前提供了MXNet,、PyTorch和TensorFlow的實(shí)現(xiàn),通過(guò)調(diào)用JNI或者JNA來(lái)調(diào)用相應(yīng)的底層操作。
新重排算法上線之服務(wù)調(diào)用方每隔一段時(shí)間就會(huì)出現(xiàn)超時(shí)報(bào)錯(cuò),每個(gè)一段時(shí)間還會(huì)出現(xiàn)OOM,這是之前從來(lái)沒(méi)有的情況,因此開(kāi)始了JVM排查之路。
排序服務(wù)是部署在K8S集群上,配置是8C16G,使用的JDK11的ZGC。


2 排查之路
2.1 NDManager
- 監(jiān)控顯示18點(diǎn)30左右,排序服務(wù)調(diào)用方有超時(shí)報(bào)錯(cuò)。查看接口的平均響應(yīng)時(shí)間,確實(shí)有尖峰(因?yàn)榕判蚍?wù)被多個(gè)服務(wù)調(diào)用,所以截取的是服務(wù)調(diào)用方展示的接口平均耗時(shí)曲線)。

- 查看CPU利用率發(fā)現(xiàn) 18:30 確實(shí)有提升,但是CPU throttled time還好。說(shuō)明響應(yīng)時(shí)間升高跟CPU throttled沒(méi)有關(guān)系,再結(jié)合OOM懷疑跟內(nèi)存泄露有關(guān)(廢話,都OOM了,肯定是跟內(nèi)存泄露有關(guān)系了,但是響應(yīng)時(shí)間升高是有很多因素引起的,還要排除CPU throttled的影響)。


- 懷疑內(nèi)存泄露第一時(shí)間查看堆的使用情況和GC情況(頻率與耗時(shí)),但是18:30 左右都是正常的。



- 問(wèn)題暫時(shí)陷入了僵局, 忽然發(fā)現(xiàn)18:30左右的超時(shí)報(bào)錯(cuò)大部分都跟某一臺(tái)機(jī)器有關(guān),查看stdout日志,發(fā)現(xiàn) 18:31:27 Tomcat被重啟了,同樣09:20:34、12:22:34 Tomcat都被kill然后重啟。這樣CPU利用率在這幾個(gè)時(shí)間點(diǎn)彪高也能解釋了。



- 排查的過(guò)程中意外發(fā)現(xiàn)Direct BufferPool使用率隨著時(shí)間逐漸增加。每次重啟之后驟降,過(guò)一段時(shí)間又增加。堆內(nèi)的使用情況又正常, 懷疑跟堆外內(nèi)存有關(guān)。


- 堆外內(nèi)存主要包括:DirectByteBuffer分配的內(nèi)存,JNI里分配的內(nèi)存,線程棧分配占用的系統(tǒng)內(nèi)存,jvm本身運(yùn)行過(guò)程分配的內(nèi)存,codeCache,java 8里還包括metaspace元數(shù)據(jù)空間。而且之前也提到了新上線基于DJL的DPP重排邏輯,會(huì)有大量使用JNI。因?yàn)閷?duì)JNI不熟悉,只是簡(jiǎn)單了解過(guò)其機(jī)制,且堆外的內(nèi)存特別難以排查,所以試著查看代碼看看有沒(méi)有什么發(fā)現(xiàn)。DPP的主要代碼如下所示(省略部分代碼和參數(shù)):
private void dppRank ( ) {
DPPModel dppModel = new DPPModel(itemCount, topK);
List<Integer> resDPP = dppModel.dppSW(/* 參數(shù)省略 */);
}
public class DPPModel {
private NDArray rankScore;
private NDArray kernelMatrix;
private NDManager manager = NDManager.newBaseManager();
// ... ...
}
這里使用的DJL的pytorch-engine:0.18.0,通過(guò)NDManger.create()創(chuàng)建NDArray,會(huì)調(diào)manager.attachInternal() 將創(chuàng)建的NDArray放到NDManager.resources中,以便統(tǒng)一管理。
public PtNDArray(PtNDManager manager, long handle) {
super(handle);
this.manager = manager;
this.ptNDArrayEx = new PtNDArrayEx(this);
manager.attachInternal(getUid(), this);
}
public synchronized void attachInternal(String resourceId, AutoCloseable resource) {
if (closed.get()) {
throw new IllegalStateException("NDManager has been closed already.");
}
tempResources.compute(
resourceId,
(key, tempResource) -> {
if (tempResource != null) {
// This state occurs when this manager (manA) tempAttaches a resource that
// is later
// tempAttached to another manager (manB)
// When manB is closed, it will use attach to return the resource to this
// (manA)
// In that case, it should stay as a tempResource in this (manA)
tempResource.detached = false;
} else {
resources.put(resourceId, resource);
}
return tempResource;
});
}
PtNDManager繼承自BaseNDManager,實(shí)現(xiàn)AutoCloseable的close接口,會(huì)遍歷resources調(diào)所有NDArray的close方法
public void close() {
if (!closed.getAndSet(true)) {
for (AutoCloseable closeable : resources.values()) {
try {
closeable.close();
} catch (Exception e) {
logger.error("Resource close failed.", e);
}
}
for (TempResource resource : tempResources.values()) {
resource.returnResource();
}
parent.detachInternal(uid);
resources.clear();
tempResources.clear();
}
}
查看PtNDManger的create 方法,會(huì)刪除NDArray的內(nèi)存。
@Override
public void close() {
Long pointer = handle.getAndSet(null);
if (pointer != null) {
JniUtils.deleteNDArray(pointer);
}
manager.detachInternal(getUid());
dataRef = null;
查到這里可以肯定是因?yàn)镈JL的使用不當(dāng)引起的, 可以通過(guò)try-with-resource方式使用NDManager,使用后會(huì)自動(dòng)釋放資源。
try (NDManager manager = NDManager.newBaseManager(Device.cpu())) {
NDArray rankScore = manager.create(rankScoreArray);
}
修改后,重新發(fā)布后,可以發(fā)現(xiàn) BufferPool direct used 使用正常。

2.2 NDManager自身bug
但是故事到這里還沒(méi)有結(jié)束,本以為調(diào)整使用方式就萬(wàn)事大吉了,但是上線一段時(shí)間后系統(tǒng)仍然報(bào)OOM、Tomcat也被重啟,只是頻率比之前低了很多,又開(kāi)始漫長(zhǎng)的排查之路。
- 查看gc.log,發(fā)現(xiàn)確實(shí)有內(nèi)存泄露,如下圖所示,只是被之前誤用的問(wèn)題掩蓋起來(lái)了。這里介紹一個(gè)網(wǎng)站https://gceasy.io/ 可以分析GC日志,發(fā)現(xiàn)GC后的堆使用情況一直是增加的。 同時(shí)Mem RSS使用量也是不斷增加的。


- 查看堆內(nèi)存dump
使用Memory Analyzer (MAT)查看堆內(nèi)存使用情況,直方圖如下所示,其中:
- Retained Heap(深堆)代表對(duì)象本身和對(duì)象關(guān)聯(lián)的對(duì)象占用的內(nèi)存;
- Shallow Heap(淺堆)代表對(duì)象本身占用的內(nèi)存。

其中有大量的PtNDManager,遠(yuǎn)超正常使用。通過(guò)list objects- with incoming referencs 查看, 發(fā)現(xiàn)被PtNDManager$SystemManager的resources引用。


每個(gè)一段時(shí)間執(zhí)行一下jmap -histo:live 也可以發(fā)現(xiàn)PtNDManager一直增加。

- 繼續(xù)查看DJL源碼
NDManager.newBaseManager()創(chuàng)建PtNDManager源碼如下所示, 會(huì)調(diào)用PtNDManager$SystemManager的attachUncappedInternal將創(chuàng)建的PtNDManager掛載到resoures里。
PtNDManager manager = new PtNDManager(this, device);
attachUncappedInternal(manager.uid, manager);
return manager;
resources.put(resourceId, resource);
當(dāng)PtNDManager執(zhí)行close方法時(shí),會(huì)調(diào)用parent的detachInternal方法,parent是PtNDManager$SystemManager, PtNDManager$SystemManager的detachInternal什么都沒(méi)干
public void close() {
if (!closed.getAndSet(true)) {
// ignore some code
parent.detachInternal(uid);
resources.clear();
tempResources.clear();
}
}
@Override
public void detachInternal(String resourceId) {}
查看pytorch-engine的0.17.0的源碼就正常多了,使用之后也沒(méi)有問(wèn)題,于是去DJL的github怒提issue: https://github.com/deepjavalibrary/djl/issues/1886
總結(jié)
此次內(nèi)存泄露有兩個(gè)原因:
- DJL使用不當(dāng),NDManager創(chuàng)建之后沒(méi)有關(guān)閉,導(dǎo)致相應(yīng)的NDArray和JNI內(nèi)存都沒(méi)有被回收;
- DJL自身bug, NDManager即使執(zhí)行了close,但是還是被引用,導(dǎo)致自身無(wú)法被回收。
雖然排查之后再進(jìn)行復(fù)盤(pán),根據(jù)各種現(xiàn)象,順理成章就排查了問(wèn)題的根源。 但是一開(kāi)始排查時(shí),毫無(wú)頭緒,各種細(xì)節(jié)充斥其中,有些是原因,有些是結(jié)果, 比如最開(kāi)始懷疑是CPU彪高導(dǎo)致響應(yīng)時(shí)間變長(zhǎng),但其實(shí)是因?yàn)镺OM導(dǎo)致tomcat 重啟導(dǎo)致的CPU彪高。只能不停地假設(shè),然后根據(jù)現(xiàn)象驗(yàn)證假設(shè),最終定位問(wèn)題。
總結(jié)內(nèi)存泄露的排查流程如下:
- 首先要熟悉JVM、包括垃圾收集器的原理,這樣才能有的放矢;
- 借助一些工具,事半功倍:本文涉及到有g(shù)raphana、cat、mat、jmap 等,每個(gè)工具都有自身的功能,幫忙定位一些問(wèn)題;
- 結(jié)合理論與工具,抓住線索,不停地去懷疑、假設(shè),然后驗(yàn)證自己的假設(shè);
- 剩下的就是交給時(shí)間,本次排查也耗時(shí)好久,開(kāi)始走了很多的彎路,但是硬剛到底最終會(huì)發(fā)現(xiàn)問(wèn)題根源。
經(jīng)過(guò)此次排查,對(duì)很多東西的理解和工具的使用更上一層樓。
最后想說(shuō)一下DJL,雖然本文的內(nèi)存泄露與DJL有關(guān),但是不得不說(shuō)DJL是一個(gè)難得的JAVA 機(jī)器學(xué)習(xí)工具包,可以在Java中開(kāi)發(fā)及應(yīng)用原生的機(jī)器學(xué)習(xí)和深度學(xué)習(xí)模型,同時(shí)簡(jiǎn)化了深度學(xué)習(xí)開(kāi)發(fā)的難度。通過(guò)DJL提供的直觀的、高級(jí)的API,Java開(kāi)發(fā)人員可以訓(xùn)練自己的模型,或者利用數(shù)據(jù)科學(xué)家用Python預(yù)先訓(xùn)練好的模型來(lái)進(jìn)行推理。如果恰好是對(duì)學(xué)習(xí)深度學(xué)習(xí)感興趣的Java開(kāi)發(fā)者,那么DJL無(wú)疑將是開(kāi)始深度學(xué)習(xí)應(yīng)用的一個(gè)最好的起點(diǎn)。