JVM之 DJL使用不当和自身bug引起的内存泄露排查

1. 背景

排序服务新上线一个DPP(Determinantal Point Process)重排算法逻辑,基于DJL(Deep Java Library)实现的, DJL目前提供了MXNet,、PyTorch和TensorFlow的实现,通过调用JNI或者JNA来调用相应的底层操作。
新重排算法上线之服务调用方每隔一段时间就会出现超时报错,每个一段时间还会出现OOM,这是之前从来没有的情况,因此开始了JVM排查之路。
排序服务是部署在K8S集群上,配置是8C16G,使用的JDK11的ZGC。

调用方超时报错
排序服务OOM事件

2 排查之路

2.1 NDManager

  1. 监控显示18点30左右,排序服务调用方有超时报错。查看接口的平均响应时间,确实有尖峰(因为排序服务被多个服务调用,所以截取的是服务调用方展示的接口平均耗时曲线)。
调用排序服务的平均响应时间
  1. 查看CPU利用率发现 18:30 确实有提升,但是CPU throttled time还好。说明响应时间升高跟CPU throttled没有关系,再结合OOM怀疑跟内存泄露有关(废话,都OOM了,肯定是跟内存泄露有关系了,但是响应时间升高是有很多因素引起的,还要排除CPU throttled的影响)。
CPU利用率
CPU throttled time
  1. 怀疑内存泄露第一时间查看堆的使用情况和GC情况(频率与耗时),但是18:30 左右都是正常的。
堆内存使用情况
zgc次数
zgc耗时
  1. 问题暂时陷入了僵局, 忽然发现18:30左右的超时报错大部分都跟某一台机器有关,查看stdout日志,发现 18:31:27 Tomcat被重启了,同样09:20:34、12:22:34 Tomcat都被kill然后重启。这样CPU利用率在这几个时间点彪高也能解释了。
微服务接口统计
stdout日志
CPU利用率曲线
  1. 排查的过程中意外发现Direct BufferPool使用率随着时间逐渐增加。每次重启之后骤降,过一段时间又增加。堆内的使用情况又正常, 怀疑跟堆外内存有关。
BufferPool
堆内内存使用情况
  1. 堆外内存主要包括:DirectByteBuffer分配的内存,JNI里分配的内存,线程栈分配占用的系统内存,jvm本身运行过程分配的内存,codeCache,java 8里还包括metaspace元数据空间。而且之前也提到了新上线基于DJL的DPP重排逻辑,会有大量使用JNI。因为对JNI不熟悉,只是简单了解过其机制,且堆外的内存特别难以排查,所以试着查看代码看看有没有什么发现。DPP的主要代码如下所示(省略部分代码和参数):
private void dppRank ( ) {
   DPPModel dppModel = new DPPModel(itemCount, topK);
   List resDPP = dppModel.dppSW(/* 参数省略 */);
}
public class DPPModel {
    private NDArray rankScore;
    private NDArray kernelMatrix;
    private NDManager manager = NDManager.newBaseManager();
    // ... ...
}

这里使用的DJL的pytorch-engine:0.18.0,通过NDManger.create()创建NDArray,会调manager.attachInternal() 将创建的NDArray放到NDManager.resources中,以便统一管理。

    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,实现AutoCloseable的close接口,会遍历resources调所有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 方法,会删除NDArray的内存。

    @Override
    public void close() {
        Long pointer = handle.getAndSet(null);
        if (pointer != null) {
            JniUtils.deleteNDArray(pointer);
        }
        manager.detachInternal(getUid());
        dataRef = null;
    

查到这里可以肯定是因为DJL的使用不当引起的, 可以通过try-with-resource方式使用NDManager,使用后会自动释放资源。

 try (NDManager manager = NDManager.newBaseManager(Device.cpu())) {
            NDArray rankScore = manager.create(rankScoreArray);
 }

修改后,重新发布后,可以发现 BufferPool direct used 使用正常。

BufferPool direct使用情况

2.2 NDManager自身bug

但是故事到这里还没有结束,本以为调整使用方式就万事大吉了,但是上线一段时间后系统仍然报OOM、Tomcat也被重启,只是频率比之前低了很多,又开始漫长的排查之路。

  1. 查看gc.log,发现确实有内存泄露,如下图所示,只是被之前误用的问题掩盖起来了。这里介绍一个网站https://gceasy.io/ 可以分析GC日志,发现GC后的堆使用情况一直是增加的。 同时Mem RSS使用量也是不断增加的。
GC后堆内存使用情况
Mem Rss使用情况
  1. 查看堆内存dump
    使用Memory Analyzer (MAT)查看堆内存使用情况,直方图如下所示,其中:
  • Retained Heap(深堆)代表对象本身和对象关联的对象占用的内存;
  • Shallow Heap(浅堆)代表对象本身占用的内存。
内存dump直方图

其中有大量的PtNDManager,远超正常使用。通过list objects- with incoming referencs 查看, 发现被PtNDManager$SystemManager的resources引用。

每个一段时间执行一下jmap -histo:live 也可以发现PtNDManager一直增加。

  1. 继续查看DJL源码
    NDManager.newBaseManager() 创建PtNDManager源码如下所示, 会调用PtNDManager$SystemManager的attachUncappedInternal将创建的PtNDManager挂载到resoures里。
   PtNDManager manager = new PtNDManager(this, device);
   attachUncappedInternal(manager.uid, manager);
   return manager;
   resources.put(resourceId, resource);

当PtNDManager执行close方法时,会调用parent的detachInternal方法,parent是PtNDManager$SystemManager, PtNDManager$SystemManager的detachInternal什么都没干

   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的源码就正常多了,使用之后也没有问题,于是去DJL的github怒提issue: https://github.com/deepjavalibrary/djl/issues/1886

总结

此次内存泄露有两个原因:

  1. DJL使用不当,NDManager创建之后没有关闭,导致相应的NDArray和JNI内存都没有被回收;
  2. DJL自身bug, NDManager即使执行了close,但是还是被引用,导致自身无法被回收。

虽然排查之后再进行复盘,根据各种现象,顺理成章就排查了问题的根源。 但是一开始排查时,毫无头绪,各种细节充斥其中,有些是原因,有些是结果, 比如最开始怀疑是CPU彪高导致响应时间变长,但其实是因为OOM导致tomcat 重启导致的CPU彪高。只能不停地假设,然后根据现象验证假设,最终定位问题。

总结内存泄露的排查流程如下:

  1. 首先要熟悉JVM、包括垃圾收集器的原理,这样才能有的放矢;
  2. 借助一些工具,事半功倍:本文涉及到有graphana、cat、mat、jmap 等,每个工具都有自身的功能,帮忙定位一些问题;
  3. 结合理论与工具,抓住线索,不停地去怀疑、假设,然后验证自己的假设;
  4. 剩下的就是交给时间,本次排查也耗时好久,开始走了很多的弯路,但是硬刚到底最终会发现问题根源。
    经过此次排查,对很多东西的理解和工具的使用更上一层楼。

最后想说一下DJL,虽然本文的内存泄露与DJL有关,但是不得不说DJL是一个难得的JAVA 机器学习工具包,可以在Java中开发及应用原生的机器学习和深度学习模型,同时简化了深度学习开发的难度。通过DJL提供的直观的、高级的API,Java开发人员可以训练自己的模型,或者利用数据科学家用Python预先训练好的模型来进行推理。如果恰好是对学习深度学习感兴趣的Java开发者,那么DJL无疑将是开始深度学习应用的一个最好的起点。

你可能感兴趣的:(JVM之 DJL使用不当和自身bug引起的内存泄露排查)