// engineering · memory
GB 级文件怎么塞进 2 GB 的 wasm 堆
1.7 GB 的影片要在 WebAssembly 线性内存里做块级分析,而那个堆的上限是 2 GB、只增不减。 解法是只装载覆盖目标 GOP 的字节窗口——实测只要 0.46 MB。但真正难的不是这个想法, 是它周围四个把程序炸掉过的坑。
一、约束:一个只增不减的堆
先把数字摆出来,因为后面所有设计都是被它们逼出来的:
| 模块 | 初始内存 | 上限 |
|---|---|---|
| 容器解析 | 64 MB | 2 GB |
| 码流分析 | 512 MB | 2 GB |
WebAssembly 的线性内存有两个要命的性质:只增长,而且增长是阶跃式的(按倍数扩容)。 已经被用过的空间不会归还给操作系统。所以任何「先把整个文件读进来」的设计, 在 GB 级文件上第一秒就出局——即便刚好塞得下,也等于把余量全部吃掉。
手机端更紧:单进程的内存预算大约 1 GB。所以超过 1 GB 的文件在手机上 物理上不可行,工具在 900 MB 处给出前置确认提示。这是一条边界,不是待优化项。
二、解法:只装覆盖目标 GOP 的字节窗口
关键认识是:块分析真正需要的不是整个文件,而是「从最近 IDR 到目标帧」这一段连续字节。 其它 99.9% 的数据在这一个请求里没有任何用处。
于是装载量变成:
装载 [winBase, winEnd) ⊂ 文件
其中 winBase = 目标 GOP 的 IDR 帧起始偏移
winEnd = 目标帧的结束偏移
喂帧时只需传相对偏移:frame.offset - winBase
长度:frame.size
实测在一部 1.7 GB 的影片上,窗口大小是 0.46 MB——比整个文件小三个数量级。
这里有一个让实现变简单的细节:C 侧的喂帧接口接收的 offset 是已装载缓冲内的相对偏移 (C 侧内部再把它叠加到缓冲基址上),而不是文件的绝对偏移。而窗口化之后,我们本来就要传相对偏移—— 两边正好对齐,不需要额外的搬运。
窗口化是一个等价变换:喂进去的字节完全相同,只是内存里那一份被平移了。 不过我们并没有「假设」它等价,而是做了 A/B:在一份 675 MB 的文件上,逐帧跑窗口版与整文件版, 两次结果哈希逐字节相同。
三、坑一:收尾会连文件缓冲一起释放
任务收尾时,解码器会把装载的文件缓冲整个释放掉,并把指针置空。这个行为本身是正确的资源管理, 但它顺手杀死了一个看起来非常自然的优化:「同一个文件只装一次」。
旧实现里就有一个这样的缓存,症状是: 同一个文件第二次做块分析,结果恒为空,且不报任何错。因为缓存判断命中之后就不会 重新装载,而缓冲早在上一轮收尾时就被释放了——喂帧时拿到的是空指针。
所以这里有一条硬规则:不要为「会自己释放自己的资源」做缓存。 窗口本身只有几 MB,重新装载大约 1 毫秒——比任何缓存的收益都便宜,也比任何缓存都安全。
四、坑二:堆只增不减,「文件小就安全」是错的
一个很自然的假设是「小文件不会出问题」。这条假设被实测推翻了。
当 C 侧出现失控(典型的是一个本该终止的循环没有终止)时,堆的消耗与输入体积无关:
它会一路按倍数扩容,直到翻过 2 GB 触发 abort()。实测一个
8.6 MB 的文件也能吃掉 1 GB 然后崩掉。
这带来两个后果:
- 「文件小」不能作为安全性假设。真正的防线是让每个可能无界的循环都有上界,以及一个字节量级的 安全阀——而不是「这个文件不大,应该没事」。
-
从 JS 侧看不到 wasm 堆用了多少。排查这类崩溃的做法是把 wasm 加载到 Node 里,
分阶段采样堆大小;同时必须打印
e.stack,因为只有这样崩溃栈里才会出现 wasm 函数名—— 否则你拿到的只是一串地址。
五、坑三:安全阀按「帧数」设是错的
回溯到 IDR 的搜索必须有一个上限,否则一个判定错误的文件会把内存吃光。最初的上限是 240 帧。
结果是某部影片恒定误报「GOP 过长」。查下去发现它的 IDR 间隔是 250 帧—— 距离 IDR 第 241 到 249 帧的那部分样本(约 3.6%)永远落在上限之外, 无论文件多么正常。
根本原因是:GOP 长度是内容的属性,不是代码的属性。用帧数当阈值, 等于对素材内容做了一个未经证实的假设——而这个假设没有任何文件能替你保证。
改成按字节量设限(128 MB)之后,阈值表达的是真正的约束 (内存),而且不会静默排除合法帧。
放开帧数限制为什么是安全的,有三条依据:
- 解码器在每帧之前会复用同一个帧对象,所以内存不随喂入的帧数累积;
-
DPB(解码图像缓冲)的大小由 SPS 里的
max_dec_frame_buffering界定,通常不超过 16 帧; - 因此「解 250 帧」与「解 25 帧」的峰值内存差别很小——差别在时间(见下节)。
但另一个阀门:向后保留帧数的上限(64 帧),是刻意保留的: 它约束的是延迟而不是内存。不要把它和回溯上限当成同一类东西一起改。
六、坑四:位流读穿之后是静默的
位流读取器越过末尾之后不会抛异常,而是静默地进入一种语义:
readBit() 恒返回 0,readUE() 返回 -1。程序不会知道它读到了不存在的地方。
所以凡是「读到哨兵才停」的循环,都必须写成双保险,并对负数显式 break:
while (!bs.eof() && n < LIMIT) {
const idc = bs.readUE();
if (idc < 0) break; // 读穿哨兵:必须显式拦
if (idc > 2) break;
...
}
这不是理论风险。真实案例是:循环里只有一个 idc > 2 的合法性判断,
而 -1 > 2 是 false,于是 -1 被当成合法值放行,循环永不退出,
堆一路翻倍直到 abort()。
七、代价:省下的是内存,不是时间
窗口化解决的是内存问题,它对时间毫无帮助。要显示落在 IDR 之后第 250 帧的画面, 就必须真的把那 250 帧解出来——帧间预测决定了没有捷径。
实测数字:
| 指标 | 值 |
|---|---|
| 单帧软解耗时 | 约 9.0 ms |
| 最坏情况(IDR 间隔 250 帧) | 约 2.2 s |
| IDR 间隔本身 | 内容的属性,不可控 |
所以这类操作必须配进度条与取消,而不是让界面卡住几秒——向后保留帧数的上限(64 帧) 约束的就是这个延迟上限。
八、可迁移的经验
- 不要为会自己释放自己的资源做缓存。缓存的命中判断必须与被缓存对象的生命周期一致, 否则失败是静默的。
- 阈值要按资源设,不要按内容的形状设。「最多 240 帧」看似保守,实际是对素材做了 一个没根据的假设;「最多 128 MB」才是直接对应约束的写法。
- 任何「读到坏数据才停」的循环,第一个要写的是上界。尤其是当底层读取器读穿之后 不报错、只返回哨兵值的时候。
- 等价变换也要验证,不要假设。窗口化 vs 整文件的哈希对拍,成本很低, 换来的是「这个优化不会改变结果」这条可以写进文档的结论。
相关:623 个关键帧,一个都没认出来 · 浏览器解码器整片拒收,那就自己解 · 技术原理 · 打开分析器
// engineering · memory
Fitting a GB-scale file into a 2 GB wasm heap
A 1.7 GB film has to be analysed block by block inside WebAssembly linear memory — a heap capped at 2 GB that only ever grows. The approach is to load only the byte window covering the target GOP, measured at 0.46 MB. The hard part was not the idea; it was the four traps around it that actually crashed the program.
1. The constraint: a heap that only grows
| Module | Initial memory | Maximum |
|---|---|---|
| Container parsing | 64 MB | 2 GB |
| Bitstream analysis | 512 MB | 2 GB |
WebAssembly linear memory has two properties that shape everything below: it only grows, and it grows in step-wise multiples. Space that has been used is never returned to the operating system. So any design that reads the whole file in is out of the running on GB-scale input within the first second — even if it barely fits, it consumes every remaining byte of headroom.
Phones are tighter still: a single-process budget of roughly 1 GB. Files above 1 GB are therefore physically infeasible on mobile, and the tool shows a confirmation prompt at 900 MB. That is a boundary, not an optimisation to be made later.
2. The approach: load only the byte window covering the target GOP
The key realisation: block analysis does not need the file. It needs the contiguous bytes from the nearest IDR up to the target frame. The other 99.9% of the file is useless for that one request.
load [winBase, winEnd) ⊂ the file
where winBase = start offset of the IDR frame opening that GOP
winEnd = end offset of the target frame
feeding a frame, as a relative offset: frame.offset - winBase
with length: frame.size
Measured on a 1.7 GB film, the window came to 0.46 MB — three orders of magnitude smaller than the file.
One detail makes the implementation simpler: the offset the C side accepts is relative to the loaded buffer (it adds it to the buffer base internally), not an absolute file offset. Since a windowed load requires relative offsets anyway, the two line up and no extra copying is needed.
Windowed loading is an equivalent transform: the same bytes are fed, just relocated in memory. We did not assume that, though — an A/B on a 675 MB file ran every frame through both the windowed and whole-file paths, and the results were byte-for-byte identical by hash.
3. Trap one: teardown frees the file buffer
When a job finishes, the decoder frees the loaded file buffer and nulls the pointer. That is correct resource management, but it also kills an optimisation that looks entirely natural: "load each file only once".
The old code had exactly that cache, and the symptom was: a second block analysis of the same file always returns empty, with no error. The cache hit meant no reload happened, but the buffer had already been freed by the previous teardown — so feeding frames used a null pointer.
Hence a hard rule: do not cache a resource that frees itself. A window is a few megabytes and reloading costs about a millisecond — cheaper and safer than any cache.
4. Trap two: the heap only grows, so "small file, no risk" is false
The natural assumption is that small files cannot run into trouble. Measurement killed it.
When the C side runs away — typically a loop that should terminate but does not — heap consumption is
unrelated to input size. It doubles its way upward until it crosses 2 GB and calls
abort(). Measured: an 8.6 MB file consumed 1 GB and then crashed.
- "The file is small" is not a safety argument. The real defences are bounded loops and a byte-level guard — not a judgement call about the input.
-
You cannot see wasm heap usage from JavaScript. The technique is to load the wasm
in Node and sample the heap at stages; and you must print
e.stack, because only then does the crash trace contain wasm function names — otherwise you get a list of addresses.
5. Trap three: a safety valve measured in frames is the wrong shape
The walk-back to an IDR needs an upper bound, otherwise a misjudged file will eat all the memory. The first bound was 240 frames.
That produced a permanent false "GOP too long" on one film. Its IDR interval turned out to be 250 frames, so the samples sitting 241 to 249 frames from an IDR — about 3.6% of them — fell outside the bound no matter how healthy the file was.
The underlying mistake: GOP length is a property of the content, not of the code. A frame-count threshold encodes an unverified assumption about the material, and no file can guarantee that assumption for you.
Switching to a byte budget (128 MB) makes the threshold express the real constraint — memory — without silently excluding valid frames.
Relaxing the frame limit is safe for three reasons:
- the decoder reuses a single frame object across frames, so memory does not accumulate with the number of frames fed;
-
the DPB (decoded picture buffer) is bounded by
max_dec_frame_bufferingin the SPS, typically no more than 16; - so peak memory for "decode 250 frames" and "decode 25 frames" is nearly the same — the difference is time (see below).
One valve was deliberately kept: the cap on frames retained (64). It bounds latency, not memory. Do not treat it as the same kind of thing as the walk-back bound.
6. Trap four: reading past the end of a bitstream is silent
A bitstream reader that runs past the end throws nothing. It enters a silent regime:
readBit() returns 0 forever and readUE() returns -1. The program has no idea
it is reading memory that does not exist.
So every "loop until you hit the sentinel" construct needs both guards, with an explicit negative break:
while (!bs.eof() && n < LIMIT) {
const idc = bs.readUE();
if (idc < 0) break; // sentinel read: must be rejected explicitly
if (idc > 2) break;
...
}
This is not theoretical. The real case: the loop's only validity check was idc > 2,
and -1 > 2 is false — so -1 passed as valid, the loop never exited, the
heap doubled upward, and the process aborted.
7. The cost: saved memory, not saved time
Windowed loading solves memory. It does nothing for time. To display the frame 250 frames after an IDR, all 250 frames have to be decoded — inter prediction leaves no shortcut.
| Metric | Value |
|---|---|
| Software decode per frame | ≈ 9.0 ms |
| Worst case (250-frame IDR interval) | ≈ 2.2 s |
| IDR interval itself | A property of the content; not controllable |
Which is why operations like this ship with a progress bar and cancellation instead of freezing the interface — and why that 64-frame retention cap exists as a latency ceiling.
8. Transferable lessons
- Never cache a resource that frees itself. A cache hit must agree with the lifetime of the cached object, or failure will be silent.
- Set thresholds on the resource, not on the shape of the content. "At most 240 frames" sounds conservative but smuggles in an assumption about the material; "at most 128 MB" speaks directly to the constraint.
- For any loop that stops on bad data, write the upper bound first — especially when the underlying reader reports nothing and just returns sentinel values.
- Verify equivalent transforms; do not assume them. Hashing windowed output against whole-file output is cheap and buys a claim you can actually write down.
Related: 623 keyframes, zero detected · when the browser decoder rejects a stream · How it works · Open the analyzer