Reduce peak memory of the JIT dependency analysis - #485
Conversation
|
Is this an AI only contribution? |
|
Most of work is done by AI including problem finding, implementation, commit and PR message, test. Is there any restriction in AI contribution? If it is, please let me know. |
cf75fa6 to
9a6f093
Compare
|
I don't think so. But AI usually means extra reviewer work. Anyway, in my experiences the biggest memory consumer is If an application asks for 32MB, or 1GB memory, we must allocate that. The default is 16MB usually. The complication memory consumption is often negligible compared to that. That is why I spent little effort on it so far. The new code has a lot of memove, memcpy operations. Is this makes a logarithmic search to polynomial? |
|
In program which allocating lot of memory in execution time, this change does not reduce max RSS as you said. But this change works in smaller memory allocation at execution time. In that case, max RSS depends on JIT compiling largest function. So this change will work for that. And the terms of time complexity, this new structure's insertion time is O(n). But max value of n is under 2,000 in my tests, and most of cells has elements two or fewer (>90%), so memory allocation or memmove is not required in that case. As a result, actual execution time does not become bigger, rather become smaller in most case. |
zherczeg
left a comment
There was a problem hiding this comment.
If I understand correctly, the key optimization is that the number of items are usually < 2.
What happens, if we would keep the set for more than 2 elements? Or an inline vector / normal vector / std::set cases
| #include "runtime/GCArray.h" | ||
|
|
||
| #include <cstdlib> | ||
| #include <cstring> |
There was a problem hiding this comment.
Why these functions are needed?
There was a problem hiding this comment.
I didn't checked that these libraries are already included in Walrus.h. My fault.
| static const VariableRef kNoRef = ~(VariableRef)0; | ||
|
|
||
| typedef std::set<VariableRef> DependencyList; | ||
| class DependencyList { |
There was a problem hiding this comment.
Is this better with vector with inline storage:
https://github.com/Samsung/walrus/blob/main/src/util/Vector.h#L552
Probably a bit better, but is it worth the code duplication?
There was a problem hiding this comment.
I checked whether this class can be replaced with inline storage vector, but inline storage vector used more memory than std::set. The size of one cell of std::set is 48 bytes while vector is 56 bytes.
And wall time becomes worse either. So I think this structure can not replaced with inline storage vector.
There was a problem hiding this comment.
Thank you. I assumed it, but good to have confirmation.
|
I measured maxRSS and wall time about three conditions you mentioned. std::set gets more wall time, but that is small part(around +0.1s), and other ways also has almost same result. There is no meaningful difference in maxRSS, because the almost every cell's (over 90%) number of elements is less than 3. |
|
I agree. I remember I used a vector before, but changed to std::set after some big programs have runtime issues. |
buildVariables() holds one dependency set per (label, stack slot) cell while
it resolves which value each slot carries at a control-flow merge. The matrix
is dense -- every label gets requiredStackSize cells whether or not the slot
is live there -- so both dimensions grow with function size and the cell count
grows quadratically. On bzip2.wasm that is 616,288 cells, and std::set made
each one cost 48 bytes empty plus a separate 40-byte node per element.
The sets are tiny. Counted over every cell of that module:
0 elements 75 0.0%
1 264,514 42.9%
2 307,486 49.9%
3 25,673 4.2%
4 or more 18,540 3.0%
So DependencyList now stores two refs inline in the object itself and spills
to a malloc'd block only past that. A cell is 16 bytes instead of 48, and 93%
of them never allocate at all. The low two bits of the first word tell the two
states apart: dependency refs are either label pointers (low bits 0) or
variable refs (low bits 1), 0 marks an empty slot, so tag 2 is free to mark a
spilled block. Elements stay sorted and deduplicated, which preserves the
std::set iteration order that mergeVariables depends on.
Peak heap while compiling every function of bzip2.wasm, by massif:
cell array (resize) 12.74 MB -> 4.25 MB
elements (insert) 19.37 MB -> 0.78 MB
module total 34.51 MB -> 7.42 MB
Peak RSS over the interpreter on ten benchmarks drops from +29.35 MB worst
case to +5.58 MB, and 483,954 node allocations become roughly 30,000 block
allocations, which also takes JIT compile time on that set from 5.14 s to
4.83 s.
The transitive-closure loop needed one change. The cell it pulls from can be
the very cell it inserts into, when a label refers to itself, and unlike a
std::set this container may move its storage on insert. It now indexes rather
than holding an iterator.
Verified against the wast suite, an assert-enabled debug build compiling every
benchmark module, and byte-identical program output on the benchmark set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: kwonjeomsim <cool5715@jbnu.ac.kr>
The dependency analysis frees everything it allocates -- massif puts the heap at 0.41 MB in its final snapshot -- but resident memory does not go back down. The allocations are many small chunks spread through the glibc main arena, so freeing them does not lower the top of the heap and nothing is returned to the operating system. MALLOC_MMAP_THRESHOLD_ and MALLOC_TRIM_THRESHOLD_ do not help, because those only govern large single allocations. malloc_trim(0) at the end of Module::jitCompile() releases it. Splitting /proc/<pid>/smaps by mapping shows it touches only [heap] and leaves the generated machine code alone, which is what we want: what it reclaims is analysis scratch that was already freed, not anything the compiled code needs. On a JIT-compiled sqlite the heap mapping goes from 27.3 MB to 6.6 MB while the executable mapping stays at 3.4 MB. jitCompile() runs once per module while the module is being parsed, so this is one call per module load. Over the 32 wasmBenchmarker programs it costs no measurable time: 14.71 s and 14.71 s for the first two of four alternating passes, and within 0.012 s on the rest. Guarded on __GLIBC__, since malloc_trim is a glibc extension. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: kwonjeomsim <cool5715@jbnu.ac.kr>
9a6f093 to
a924329
Compare
|
I have replaced array to std::set when elements over 2. But some wrapper iterator is added to address different two data structure. |
Cuts the peak memory that
JITCompiler::buildVariables()needs while it resolveswhich value each stack slot carries at a control-flow merge, and returns what is
left over to the operating system when compilation finishes.
Two commits, independent of each other:
std::set— the real fix.malloc_trim— releases what theanalysis already freed.
Why the analysis dominates peak memory
DependencyGenContextholds one dependency set per (label, stack slot) cell. Thematrix is dense: every label gets
requiredStackSizecells whether or not theslot is live there, so both dimensions grow with function size and the cell count
grows quadratically.
bzip2.wasmproduces 413,652 cells, and withstd::seteachone cost 48 bytes while empty plus a separately allocated 40-byte node per element.
(*bzip2.wasm is external benchmark - from human writer)
The sets are tiny. Counted over every cell of that module:
So
DependencyListnow keeps two refs inline in the object and spills to amalloc'd block only past that. A cell is 16 bytes instead of 48, and 92% of them
never allocate at all. The low two bits of the first word separate the two states:
dependency refs are either label pointers (low bits 0) or variable refs (low bits
1), and 0 marks an empty slot, so tag 2 is free to mark a spilled block. Elements
stay sorted and deduplicated, which preserves the
std::setiteration order thatmergeVariablesdepends on.Measurements
valgrind --tool=massif --time-unit=Bovertest/wasmBenchmarker/ctests/wasm,run as
walrus --jit <module>. Both binaries come from the same tree; "before"only has
Analysis.cppreverted to its current upstream state. Numbers areuseful-heapat the peak snapshot. The "analysis" columns attribute the peaktree to
Analysis.cpp.The top of the peak tree changes shape. Before, the largest single contributor is
red-black tree node allocation:
After, that chain is gone and only the cell array itself is left:
These modules are small (7-20 KB of wasm), so the absolute numbers are small. The
effect scales with the square of function size. Compiling every function of
bzip2.wasmpeaks at 34.51 MB before and 7.42 MB after, split as:resize)insert)Across ten larger benchmarks, peak RSS over the interpreter drops from +29.35 MB
worst case to +5.58 MB. 483,954 node allocations become roughly 30,000 block
allocations, which also takes JIT compile time on that set from 5.14 s to 4.83 s.
The one behavioural subtlety
The transitive-closure loop in
buildVariables()can pull from the very cell itinserts into, when a label refers to itself. A
std::setnever moves its nodes,so iterating while inserting was safe; this container may move its storage. The
loop now indexes rather than holding an iterator. (Please check lines 709 to 717 at Analysis.cpp)
malloc_trim
The analysis frees everything it allocates -- massif puts the heap at 0.41 MB in
its final snapshot -- but resident memory does not go back down, because the
allocations are many small chunks spread through the glibc main arena.
MALLOC_MMAP_THRESHOLD_andMALLOC_TRIM_THRESHOLD_do not help, since thoseonly govern large single allocations.
Splitting
/proc/<pid>/smapsby mapping showsmalloc_trim(0)touches only[heap]and leaves the generated machine code alone. On a JIT-compiled sqlite theheap mapping goes from 27.3 MB to 6.6 MB while the executable mapping stays at
3.4 MB.
jitCompile()runs once per module while the module is parsed, so this is onecall per module load. Over the 32 wasmBenchmarker programs it costs no measurable
time: 14.71 s and 14.71 s for the first two of four alternating passes, and within
0.012 s on the rest.
Verification
tools/run-tests.pypasses 130/130 acrossbasic-tests,jit,wasi,wasm-test-core,wasm-test-extended,wasm-test-web-assembly3.wasmBenchmarkerprograms produce byte-identical output before and after.(
redBlack.wasmfails withundefined elementon both, unrelated to this change.)an assertion.
🤖 Generated with Claude Code