使用 wasm 提高前端20倍的 md5 计算速度

5,596 阅读4分钟

背景

  • 前阵子做大文件上传,本地内网测试发现非常慢,查了很久问题发现前端 MD5 计算消耗了非常多的时间。
  • 知道问题在哪那么就该想办法优化了,但以常规的优化很难有质的提升。
  • 最近不是流行 WebAssembly 吗,听说性能非常高,于是就决定自己写个 wasmMD5 库。
  • 看看 wasm 对前端能提升多少,也顺便体验下 WebAssembly 的开发。

开始

  • 此前我并未使用过 wasm 相关的东西,根据 mdn 的介绍可以用 c/c++rust 编写。
  • 我选择使用 rust 开发,并根据 mdn 的文档下载了 wasm-pack wasm 开发工具 (需要会使用 rust 相关工具链)
  • 折腾了两天编译了第一版 wasm 代码

第一版 wasm.png

  • 它是通过异步请求 .wasm 文件的形式来加载 wasm 程序的,这也是 mdn 上的标准做法
<script type="module">
    import init, {Md5, Sha256, Sha512} from "./dist/digest_wasm.js"
    window.onload = async () => {
        await init() // 需要先初始化加载 wasm
        /* 后续的操作 */
    }
</script>
  • 这种是适用于 webnode 使用可能会有问题,找不到 wasm 文件或者用构建工具时没能正确将 wasm 一起打包
  • 研究了一下 wasm-pack 编译后的文件,发现可以删除掉一部分初始化代码改为 base64 内嵌代码,这样就不需要异步请求,同构为 web node 通用的代码
// patch.mjs
import {readFile, writeFile} from "fs/promises"

const name = "digest_wasm"
const base = "./dist/lib"

const input = await readFile(`${base}/${name}.js`, "utf8")
const code = input
    .replace(/async function __wbg_load[\s\S]*?(?=function __wbg_get_imports)/, "")
    .replace(/async function __wbg_init[\s\S]*/, "")
    .replace(/ *__wbg_init.__wbindgen_wasm_module = module;[\n\r]*/, "")

const output = `${code}
const __toBinary = (() => {
    const table = new Uint8Array(128);
    for (let i = 0; i < 64; i++) {
        table[i < 26 ? i + 65 : i < 52 ? i + 71 : i < 62 ? i - 4 : i * 4 - 205] = i;
    }
    return (base64) => {
        const n = base64.length
        const bytes = new Uint8Array((n - (base64[n - 1] === "=") - (base64[n - 2] === "=")) * 3 / 4 | 0);
        let i = 0, j = 0;
        while (i < n) {
            const c0 = table[base64.charCodeAt(i++)], c1 = table[base64.charCodeAt(i++)];
            const c2 = table[base64.charCodeAt(i++)], c3 = table[base64.charCodeAt(i++)];
            bytes[j++] = c0 << 2 | c1 >> 4;
            bytes[j++] = c1 << 4 | c2 >> 2;
            bytes[j++] = c2 << 6 | c3;
        }
        return bytes;
    };
})();

const bytes = __toBinary(${JSON.stringify(await readFile(`${base}/${name}_bg.wasm`, 'base64'))});

initSync(bytes)
`

await writeFile(`${base}/../${name}.js`, output)
  • 再次编译并使用 patch.mjs 来同构编译后的代码,这次不再有 init 方法来异步加载了 开箱即用

同构优化.png

测试

  • 以防万一编译后有差错,需要编写一份测试脚本
import {Md5, Sha256, Sha512} from "./dist/digest_wasm.js"
import {readFile} from "fs/promises"

const data = await readFile("./LICENSE")

const md5 = await Md5.digest_u8(new Uint8Array(data))
console.assert(md5 === "d229da563da18fe5d58cd95a6467d584", "MD5 计算结果不正确")

const sha256 = await Sha256.digest_u8(new Uint8Array(data))
console.assert(sha256 === "1eb85fc97224598dad1852b5d6483bbcf0aa8608790dcc657a5a2a761ae9c8c6", "SHA256 计算结果不正确")

const sha512 = await Sha512.digest_u8(new Uint8Array(data))
console.assert(sha512 === "e2f81cb44129e1bc58941e7b3db1ffba40357889bace4fd65fd254d0be1bb757625bdf36bf46d555eb3ca4b130dcd1c05225caec28d8472dccf52a63dbd6e185", "SHA512 计算结果不正确")

构建

  • 使用 wasm-pack --release --target web 打包成 js 代码
  • 运行 patch.mjs 脚本修改为同构代码
  • 运行 test.mjs 测试脚本检查是否有错误
wasm-pack build --release --target web --out-dir dist/lib && node patch.mjs && node test.mjs

调优

  • 默认编译配置是减少体积的,MD5 计算需要消耗大量时间,这种时候应该以性能为主
  • 修改 Cargo.toml 配置
[profile.release]
opt-level = 3 # 开启全面优化
lto = true # 开启编译时间更久的极致优化
strip = true
panic = "abort"

[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-O4"] # 这个应该是 LLVM 的参数 O4 优化

测速

  • 我的 CPU 是 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
  • 测试结果为 4G ZIP 文件计算 MD5 耗时 7-8 秒

测速.png

  • SparkMd5的测速

spark1.png

spark2.png

  • 不知道是不是我使用的问题 SparkMd5 跑起来贼慢,而且跑起来笔记本风扇贼响
const fileHash = async (file, chunkSize = 128 << 20) => {
    const hash = new SparkMD5.ArrayBuffer()
    for (let i = 0; i < Math.ceil(file.size / chunkSize); i++) {
        hash.append(await file.slice(chunkSize * i, chunkSize * (i + 1)).arrayBuffer())
    }
    return hash.end()
}

使用

  • pnpm install digest-wasm
import {Md5, Sha256, Sha512} from "digest-wasm";

const md5 = await Md5.digest("Hello World!")
const buffer = await new Blob(["Hello World!"]).arrayBuffer()
const sha256 = await Sha256.digest_u8(new Uint8Array(buffer))

const file_hash = async (file, chunkSize = 128 << 20) => {
    const hash = Md5.new()
    // const hash = Sha256.new()
    // const hash = Sha512.new()

    for (let i = 0; i < Math.ceil(file.size / chunkSize); i++) {
        const chuck = await file.slice(chunkSize * i, chunkSize * (i + 1)).arrayBuffer()
        await hash.update(new Uint8Array(chuck))
    }

    return hash.finalize()
}

相关