背景

事情是这样的,最近在做一个 emoji-search 的个人 Project,为了削减服务器的建立及维护工作,我把 emoji 的 embedding 数据放到了本地,即 Android 设备上。这个文件的原始巨细为 123M,运用 gzip 压缩之后,巨细为 47.1M,文件每行都能够解析成一个 Json 的 Bean。文件的详细内容能够检查该 链接。

// 文件行数为:3753
// embed 向量维度为:3072
{"emoji": "\ud83e\udd47", "message": "1st place medal", "embed": [-0.018469301983714104, -0.004823130089789629, ...]}
{"emoji": "\ud83e\udd48", "message": "2nd place medal", "embed": [-0.023217657580971718, -0.0019081177888438106, ...]}

emoji 的 embedding 数据,记录了每个 emoji 的 token 向量。用来做 emoji 的查找。将用户输入的 embedding 和 emoji 的 embedding 数据做点积,得到点积较大的 emoji,即用户的查找结果。

Android 测验机装备如下:

hw.cpu 高通 SDM765G
hw.cpu.ncore 8
hw.device.name OPPO Reno3 Pro 5G
hw.ramSize 8G
image.androidVersion.api 33

小胆测验

为了方便读取,我将文件放在了 raw 文件夹下,命名为 emoji_embeddings.gz。关键代码如下,这儿我将 .gz 文件一次性加载到内存,然后逐行读取。

override suspend fun process(context: Context) = withContext(Dispatchers.IO) {
    context.resources.openRawResource(R.raw.emoji_embeddings).use { inputStream ->
        GZIPInputStream(inputStream).bufferedReader().use { bufferedReader ->
            bufferedReader.readLines().forEachIndexed { index, line ->
                val entity = gson.fromJson(line, EmojiJsonEntity::class.java)
                // process entity
            }
        }
    }
}

结果可想而知,由于文件比较大,读取文件到内存的时刻大概在 13s 左右。

而且在读取的过程中,内存颤动比较严重,这十分影响用户体验。

将文件一次性加载到内存,占用的内存也比较大,大概在 260M 左右,内存严重的状况下容易出现 OOM。

Android 当你需要读一个 47M 的 json.gz 文件

于是,接下来的工作,便是优化内存的运用和削减加载的耗时了。

优化内存运用

  • 逐行加载文件

    很显然,咱们最好不要将文件一次性加载到内存中,这样内存占用比较大,容易 OOM,咱们能够运用 ReaderuseLines API。类似于这样调用 bufferedReader().useLines{ } ,其原理为 Sequence + reader.readLine() 的完成。再运用 Flow 简略切一下线程,数据读取在 IO Dispatcher,数据处理在 Default Dispatcher。代码如下:

    override suspend fun process(context: Context) = withContext(Dispatchers.Default) {
        flow {
            context.resources.openRawResource(R.raw.emoji_embeddings_json).use { inputStream ->
                GZIPInputStream(inputStream).use { gzipInputStream ->
                    gzipInputStream.bufferedReader().useLines { lines ->
                        for (line in lines) {
                            emit(line)
                        }
                    }
                }
            }
        }.flowOn(Dispatchers.IO)
            .collect {
                val entity = gson.fromJson(it, EmojiJsonEntity::class.java)
                // process entity
            }
    }
    

    但这样会导致另一个问题,那便是内存颤动。由于逐行加载到内存中,当时行运用完之后,就会等待 GC,这儿暂时无法处理。

    完成之后,加载时的内存能够从 260M 削减到 140M 左右,加载时刻控制在 9s 左右。

Android 当你需要读一个 47M 的 json.gz 文件

  • 削减内存颤动

    经过检查代码,并运用 Profile 进行调试,咱们能够发现,其实首要的 GC 操作频繁,首要是由这行代码导致的: line.toBean<EmojiJsonEntity>() 。这儿会存在 EmojiJsonEntity 目标的创建操作,可是 EmojiJsonEntity 只作为中心变量进行存在和运用,所以创建完成之后,就会进行收回。那要怎样处理这个问题呢?

    笔者暂时没找到较好的解法,这儿需求确保代码逻辑不过于杂乱的一起,消除中心变量的创建。暂时先这样吧。有时刻能够运用目标池试试。

削减加载耗时

  • 找到最长耗时路径

    测验下来,IO 大概耗时 3.8s,可是总的耗时在 9s。这儿我指定了 IO 运用 IO 协程调度器,数据处理运用 Default 协程调度器,IO 和数据处理是并行的。所以总的来说,是数据处理在拖后腿。数据处理首要是这部分代码 line.toBean<EmojiJsonEntity>() 的耗时,运用 Gson 库进行一次 fromJson 的操作。这儿咱们一步一步来,先来处理 IO 耗时的问题。

  • 加速 IO 操作

    笔者暂时想到了以下两种处理方法:

    1. 单个流分段读取

      在 GZIP 文件中,数据被压缩成连续的块,而且每个块的压缩是相关于前一个块的数据进行的。这就意味咱们不能只读取文件的一部分并解压它,由于咱们需求前面的数据来正确解码当时的块。所以,关于 GZIP 文件来说,完成分段读取有一些困难。这个想法,暂时先搁置吧。

    2. 多个流分段读取

      • 同一个文件敞开多个流

        回到 GZIP 的评论,同一个文件敞开多个流也是白费的。由于即使多个线程处理各自的流,然后每个线程处理该文件的一部分,这也需求每个流从头开始对 GZIP 文件进行解压,然后跳过自己无需处理的部分。这么算下来,其实并不能加速总的 IO 速度,一起也会造成 CPU 资源的糟蹋。

      • 将文件拆分红多个文件之后敞开多个流

        考虑这样的一种完成方法:对原有的 GZIP 文件进行拆分,拆分红多个小的 GZIP 文件,运用多线程读取,运用多核 CPU 加速 IO。听起来好像可行,咱们赶忙完成一下:

        override suspend fun process(context: Context) = withContext(Dispatchers.Default) {
            val mutex = Mutex()
            List(STREAM_SIZE) { i ->
                flow {
                    val resId = getEmbeddingResId(i) // 获取当时的资源文件 Id
                    context.resources.openRawResource(resId).use { inputStream ->
                        GZIPInputStream(inputStream).use { gzipInputStream ->
                            gzipInputStream.bufferedReader().useLines { lines ->
                                for (line in lines) {
                                    emit(line)
                                }
                            }
                        }
                    }
                }.flowOn(Dispatchers.IO)
            }.asFlow()
                .flattenMerge(STREAM_SIZE)
                .collect { data ->
                    val entity = gson.fromJson(data, EmojiJsonEntity::class.java)
                    mutex.withLock {
                        // process entity
                    }
                }
        }
        

        笔者将之前的 json.gz 拆分红了 5 个文件,每个文件发动一个流去加载。之后再将这 5 个流经过 flattenMerge 合并成一个流,来进行数据处理。由于 flattenMerge 有多线程操作,所以这儿咱们运用协程的 Mutex 加个锁,确保数据操作的原子性。

        实践测验下来,如此操作的 IO 耗时在 2s,缩短为原来的一半,但总的耗时仍是稳定在了 9s 左右,这多出来的 2s 详细花在哪里了暂时不知道,咱接着优化一下数据处理吧‍。

    Android 当你需要读一个 47M 的 json.gz 文件

  • 缩短数据处理时刻的计划剖析

    先明确一下需求:咱们需求将文件一次性加载到内存中,文件巨细为 40M+,其中有每行都有一个 3072 个元素的 float 数组。了解了一圈下来,现在知道的可行的计划有两个,而且大概率需求替换数据结构和存储方法:

    1. 数据库(如 Room):在一些特定的状况下,运用数据库可能会有利,如当咱们需求进行杂乱查询、更新数据、或者需求随机拜访数据的时候。如果需求运用数据库来缩短数据处理时刻,那么咱们需求在写入时就处理好数据格式,比如当时状况下,咱们需求将 float 数组运用 BLOB 字段来存储。然而,在当时需求下,咱们的数据相对简略,且只需求进行读操作。而且,咱们的数据包含很多的浮点数数组,运用 BLOB 字段来存储也会较为杂乱。因而,数据库可能不是最理想的挑选。

    2. Protocol Buffers (PB):PB 是一个二进制格式,比文本格式(如 JSON)更紧凑,更快,特别擅长存储和读取很多的数值数据(如 embed 数组)。咱们的需求首要是读取数据,而且需求一次性将整个文件加载到内存中。因而,PB 可能是一个不错的挑选。尽管 PB 数据不易于阅览和编辑,也不适合需求杂乱查询或随机拜访的状况。

    Android 当你需要读一个 47M 的 json.gz 文件

    如上是 PB 和 Json 序列化和反序列化的比照 ref。能够看到,在一次反序列化操作的状况下, PB 是 Json 的 5 倍。次数越多,差距越大。

    关于为什么二进制文件(PB)会比文本文件(Json) 体积更小,读写更快。这儿就不过多赘述了,笔者个人了解,简略来说,是信息密度的差异,详细的大家能够去查找,了解更多。

    总的来说,考虑到咱们当时的需求(首要是读操作,且文件较大),运用 Protocol Buffers 会比较合适。

  • 运用 Protocol Buffers (PB) 存储 embedding 数据

    PB 文件比 Json 文件的读取要杂乱不少,首要咱们需求界说一下 proto 文件的格式。

    这儿的 repeated float 能够了解成 float 类型的 List

    // emoji_embedding.proto
    syntax = "proto3";
    message EmojiEmbedding {
        string emoji = 1;
        string message = 2;
        repeated float embed = 3;
    }
    

    界说好之后,就能够进行数据的序列化操作了。值得一提的是,pb.gz 文件是 json.gz 文件的一半巨细,只要 21.5M。在数据序列化的时候,笔者运用了 4 字节(Byte) 来存储单条数据的长度,方便之后的数据反序列化操作。这儿咱们直接看一下 Android 反序列化 PB 文件的代码:

    override suspend fun process(context: Context) = withContext(Dispatchers.Default) {
        flow {
            context.resources.openRawResource(R.raw.emoji_embeddings_proto).use { inputStream ->
                GZIPInputStream(inputStream).buffered().use { gzipInputStream ->
                    DataInputStream(gzipInputStream).use { dataInputStream ->
                        try {
                            while (true) {
                                // 运用 4 字节存储文件长度,即一个 int 类型的长度,
                                // 所以这儿直接 readInt()
                                val length = dataInputStream.readInt()
                                val byteArray = ByteArray(length)
                                dataInputStream.readFully(byteArray) // read message content
                                emit(byteArray)
                            }
                        } catch (e: EOFException) {
                            Log.d(TAG, "process: EOFException, end of file.")
                        }
                    }
                }
            }
        }.flowOn(Dispatchers.IO)
            .buffer()
            .flatMapMerge { byteArray ->
                flow { emit(readEmojiData(byteArray)) }
            }.collect {}
    }
    private fun readEmojiData(byteArray: ByteArray) {
        val entity = EmojiEmbeddingOuterClass.EmojiEmbedding.parseFrom(byteArray)
        // process entity
    }
    

    这儿由于有生成的 EmojiEmbeddingOuterClass 代码,所以解析起来还算方便,解析完操作 entity 即可。值得注意的是,我运用 flatMapMerge 来完成多线程处理,而不是运用 launch/async ,这儿的目的是削减协程的创建,削减上下文的切换,削减并发数,来进步数据处理的速度。由于实践测验下来,flatMapMerge 的速度会更快。

    那么这么做的实践效果如何呢?1.5s!比 Json 实在是好太多了 (这儿由于开了 build with Profile,会比实践的慢一点)。稳定下来时,内存占用 170 M。

    Android 当你需要读一个 47M 的 json.gz 文件

  • 运用多个拆分的 Protocol Buffers (PB) 文件

    到这儿,差不多要结束了,可是咱们还差了一点点,便是将拆分的 pb.gz 文件进行多线程 IO 读取。代码就不贴了,都是差不多的逻辑。

    实践测验和单个 pb 文件差不太多,这儿 IO 是会快一些的,猜测是 IO 占用了数据处理的 CPU 吧,详细原因暂时没有去深究了。如果要剖析的话,能够用 Profile dump CPU 的 trace 看看详细的运转状况。

    Android 当你需要读一个 47M 的 json.gz 文件

总结

大文件的读写,咱仍是老老实实用字节码文件存储吧。小文件能够运用 Json,反序列化速度够用,可读性也能够有明显的提高。详细的功能比照,图表如下:

json.gz + 一次性加载 json.gz + 逐行加载 拆分 json.gz + 逐行加载 加载 pb.gz 加载拆分 pb.gz
耗时 13s 9s 9s 1.5s 1.5s
内存(加载后) 260M 140M 148M 170M 170M

用到的资源文件:github.com/sunnyswag/e…

源代码可检查:Github

REFERENCE

深入了解gzip原理 – 简书

Protobuf 和 JSON比照剖析 –

Android Studio 装备并运用Protocol Buffer生成java文件 – CSDN博客