TL;DR: This may just be the expected result of working with THP, that you pay for the management and receive benefits over the long run, but I was still rather surprised.
All of this is with v3.4.4 of mimalloc
Benchmark 1: env MVM_SPESH_BLOCKING=0 MIMALLOC_ALLOW_THP=0 rakudo -ne 'BEGIN my %h; if /\s+(\d+)/ { %h{$0}++; }; last if $++ > 1000; END say +%h.keys' gen/moar/CORE.*setting
Time (mean ± σ): 230.1 ms ± 3.8 ms [User: 164.3 ms, System: 62.3 ms]
Range (min … max): 223.8 ms … 237.1 ms 20 runs
Benchmark 2: env MVM_SPESH_BLOCKING=0 MIMALLOC_ALLOW_THP=1 rakudo -ne 'BEGIN my %h; if /\s+(\d+)/ { %h{$0}++; }; last if $++ > 1000; END say +%h.keys' gen/moar/CORE.*setting
Time (mean ± σ): 439.5 ms ± 35.2 ms [User: 164.9 ms, System: 270.2 ms]
Range (min … max): 360.0 ms … 490.9 ms 20 runs
Summary
env MVM_SPESH_BLOCKING=0 MIMALLOC_ALLOW_THP=0 rakudo -ne 'BEGIN my %h; if /\s+(\d+)/ { %h{$0}++; }; last if $++ > 1000; END say +%h.keys' gen/moar/CORE.*setting ran
1.91 ± 0.16 times faster than env MVM_SPESH_BLOCKING=0 MIMALLOC_ALLOW_THP=1 rakudo -ne 'BEGIN my %h; if /\s+(\d+)/ { %h{$0}++; }; last if $++ > 1000; END say +%h.keys' gen/moar/CORE.*setting
You can see the disparity in system time is about 190 milliseconds and the range of wall-clock time is 14ms wide with THP turned off, and 130ms wide with THP turned on.
I made a perf recording of my program once with THP allowed and once with THP disallowed with this command:
env MVM_SPESH_BLOCKING=0 MIMALLOC_ALLOW_THP=1 perf record --user-callchains -g --call-graph fp -F 20000 rakudo -ne 'BEGIN my %h; if /\s+(\d+)/ { %h{$0}++; }; last if $++ > 1000; END say +%h.keys' gen/moar/CORE.*setting
The results in the report are rather large, so I collapsed them. Takeaway is that with THP on it recorded 9k samples for the same workload that recorded 4k samples without THP.
Results of a perf recording with THP disallowed and one with THP allowed:
command for the reports: perf report --stdio --parent "mi_page_queue_find_free_ex" --exclude-other --sort=parent
# Total Lost Samples: 0
#
# Samples: 4K of event 'cpu/cycles/P'
# Event count (approx.): 1047552711
#
# Children Self Parent symbol
# ........ ........ ......................................
#
7.54% 7.54% mi_page_queue_find_free_ex.constprop.0
|
|--5.73%--0x6d2e366c7265702f
| main
| |
| |--5.08%--MVM_interp_run
| | |
| | |--1.29%--MVM_6model_get_how
| | | MVM_6model_get_how
| | | MVM_gc_write_barrier (inlined)
| | | MVM_serialization_demand_object
| | | work_loop
| | | deserialize_object (inlined)
| | | |
| | | --1.20%--deserialize
| | | |
| | | --0.76%--MVM_gc_write_barrier (inlined)
| | | |
| | | --0.67%--MVM_serialization_read_ref
| | | MVM_gc_allocate_object
| | | MVM_gc_gen2_allocate_zeroed
| | | MVM_gc_gen2_allocate
| | | add_page (inlined)
| | | MVM_malloc (inlined)
| | | _mi_malloc_generic
| | | mi_page_queue_find_free_ex (inlined)
| | | |
| | | --0.53%--mi_page_free_list_extend
| | | |
| | | --0.51%--mi_block_set_next (inlined)
| | | mi_block_set_nextx (inlined)
| | |
| | --1.00%--MVM_serialization_deserialize
| | |
| | --0.87%--repossess
| | |
| | --0.80%--MVM_serialization_demand_stable
| | MVM_gc_root_temp_pop (inlined)
| | read_param_intern
| | |
| | --0.70%--MVM_gc_allocate_gen2_default_set (inlined)
| | MVM_serialization_demand_object
| | work_loop
| | |
| | --0.51%--deserialize_object (inlined)
[...]
and here the same setup but with THP allowed
# Total Lost Samples: 0
#
# Samples: 9K of event 'cpu/cycles/P'
# Event count (approx.): 2347591455
#
# Children Self Parent symbol
# ........ ........ ......................................
#
47.52% 47.52% mi_page_queue_find_free_ex.constprop.0
|
|--45.05%--0x6d2e366c7265702f
| main
| |
| |--43.79%--MVM_interp_run
| | |
| | |--31.71%--MVM_load_bytecode
| | | MVM_cu_map_from_file
| | | MVM_cu_from_bytes
| | | MVM_bytecode_unpack
| | | |
| | | |--30.13%--deserialize_frames (inlined)
| | | | |
| | | | |--29.49%--MVM_repr_alloc_init
| | | | | MVM_gc_allocate_object
| | | | | MVM_gc_gen2_allocate_zeroed
| | | | | MVM_gc_gen2_allocate
| | | | | add_page (inlined)
| | | | | MVM_malloc (inlined)
| | | | | _mi_malloc_generic
| | | | | mi_page_queue_find_free_ex (inlined)
| | | | | |
| | | | | --29.45%--mi_page_fresh_alloc
| | | | | mi_arenas_page_regular_alloc
| | | | | mi_arenas_page_regular_alloc
| | | | | mi_page_free_list_extend
| | | | | mi_block_set_next (inlined)
| | | | | mi_block_set_nextx (inlined)
| | | | |
| | | | --0.60%--MVM_malloc (inlined)
| | | | _mi_malloc_generic
| | | | mi_page_queue_find_free_ex (inlined)
| | | | mi_page_fresh_alloc
| | | | mi_arenas_page_regular_alloc
| | | | mi_arenas_page_regular_alloc
| | | | mi_page_free_list_extend
| | | | mi_block_set_next (inlined)
| | | | mi_block_set_nextx (inlined)
[...]
I only noticed this because there was a suspiciously long period on the bytecode specializer thread in MoarVM when it did its first batch of work, and for that period, basically 90% of samples were under mi_theap_realloc_zero going all the way into mi_block_set_nextx which had all of the self samples; mi_block_set_nextx is highlighted in darker yellow in the following two pictures:
and there were similar measurements the next time it worked:
these were recorded with the bytecode specializer thread in "blocking" mode, so that the thread that submitted the work waits for it to complete. that's why the main thread doesn't wake up at all during that time.
The main thread also has a few samples with mi_block_set_nextx but very noticeably less.
It makes sense for the workload in question where the main thread really only works with small arrays and hashes that die young, so memory usage wouldn't grow, whereas the specializer thread would be doing different-sized amounts of work for each bytecode frame it encounters, including potentially quite large temporary arrays.
running the workload with MIMALLOC_VERBOSE=1 MIMALLOC_SHOW_STATS=1 and THP allowed and disallowed. the output looks almost the same, but one has 1 fault to the other's 0, and of course the time taken for the THP allowed one is a bit longer by roughly 200ms system time
allowed:
mimalloc: process init: 0x7FCA03254000
v3.4.4 (built on Aug 24 2026, 14:57:01)
option 'show_errors': 0
option 'show_stats': 1
option 'verbose': 1
option 'deprecated_eager_commit': 1
option 'arena_eager_commit': 2
option 'purge_decommits': 1
option 'allow_large_os_pages': 0
option 'reserve_huge_os_pages': 0
option 'reserve_huge_os_pages_at': -1
option 'reserve_os_memory': 0 KiB
option 'deprecated_segment_cache': 0
option 'deprecated_page_reset': 0
option 'deprecated_abandoned_page_purge': 0
option 'deprecated_segment_reset': 0
option 'deprecated_eager_commit_delay': 1
option 'purge_delay': 1000
option 'use_numa_nodes': 0
option 'disallow_os_alloc': 0
option 'os_tag': 100
option 'max_errors': 32
option 'max_warnings': 32
option 'deprecated_max_segment_reclaim': 10
option 'destroy_on_exit': 0
option 'arena_reserve': 1048576 KiB
option 'arena_purge_mult': 1
option 'deprecated_purge_extend_delay': 1
option 'disallow_arena_alloc': 0
option 'retry_on_oom': 400
option 'deprecated_visit_abandoned': 1
option 'guarded_min': 0
option 'guarded_max': 1073741824
option 'guarded_precise': 0
option 'guarded_sample_rate': 0
option 'guarded_sample_seed': 0
option 'generic_collect': 10000
option 'page_reclaim_on_free': 0
option 'page_full_retain': 2
option 'page_max_candidates': 4
option 'max_vabits': 0
option 'pagemap_commit': 0
option 'page_commit_on_demand': 0
option 'page_max_reclaim': -1
option 'page_cross_thread_max_reclaim': 32
option 'allow_thp': 1
option 'minimal_purge_size': 0 KiB
option 'arena_max_object_size': 2097152 KiB
option 'arena_is_numa_local': 0
debug level : 0
secure level: 0
mem tracking: none
mimalloc: reserved 1048576 KiB memory
mimalloc: using 5 numa regions
4
subproc 0
pages peak total current block total#
touched : 0 0 0 ok
pages : 717 1.2 K 700
abandoned : 550 837 271
reclaima : 0
reclaimf : 560
reabandon : 0
waits : 0
extended : 0
retire : 0
searches : 2.4 avg
arenas peak total current block total#
reserved : 1.0 GiB 1.0 GiB 1.0 GiB
committed : 191.4 MiB 191.4 MiB 191.4 MiB
reset : 0
purged : 2.1 MiB
arenas : 1
rollback : 0
mmaps : 3
commits : 0
resets : 0
purges : 29
guarded : 0
theaps : 1 1 1
heaps : 1 1 1
heap waits: 0
process peak total current block total#
threads : 2 2 2
numa nodes: 5
elapsed : 0.527 s
process : user: 0.185 s, system: 0.256 s, faults: 55, peak rss: 193.7 MiB, peak commit: 191.4 MiB
mimalloc: process done: 0x7FCA03254000
disallowed:
mimalloc: process init: 0x7FD95A8E2000
v3.4.4 (built on Aug 24 2026, 14:57:01)
option 'show_errors': 0
option 'show_stats': 1
option 'verbose': 1
option 'deprecated_eager_commit': 1
option 'arena_eager_commit': 2
option 'purge_decommits': 1
option 'allow_large_os_pages': 0
option 'reserve_huge_os_pages': 0
option 'reserve_huge_os_pages_at': -1
option 'reserve_os_memory': 0 KiB
option 'deprecated_segment_cache': 0
option 'deprecated_page_reset': 0
option 'deprecated_abandoned_page_purge': 0
option 'deprecated_segment_reset': 0
option 'deprecated_eager_commit_delay': 1
option 'purge_delay': 1000
option 'use_numa_nodes': 0
option 'disallow_os_alloc': 0
option 'os_tag': 100
option 'max_errors': 32
option 'max_warnings': 32
option 'deprecated_max_segment_reclaim': 10
option 'destroy_on_exit': 0
option 'arena_reserve': 1048576 KiB
option 'arena_purge_mult': 1
option 'deprecated_purge_extend_delay': 1
option 'disallow_arena_alloc': 0
option 'retry_on_oom': 400
option 'deprecated_visit_abandoned': 1
option 'guarded_min': 0
option 'guarded_max': 1073741824
option 'guarded_precise': 0
option 'guarded_sample_rate': 0
option 'guarded_sample_seed': 0
option 'generic_collect': 10000
option 'page_reclaim_on_free': 0
option 'page_full_retain': 2
option 'page_max_candidates': 4
option 'max_vabits': 0
option 'pagemap_commit': 0
option 'page_commit_on_demand': 0
option 'page_max_reclaim': -1
option 'page_cross_thread_max_reclaim': 32
option 'allow_thp': 0
option 'minimal_purge_size': 0 KiB
option 'arena_max_object_size': 2097152 KiB
option 'arena_is_numa_local': 0
debug level : 0
secure level: 0
mem tracking: none
mimalloc: reserved 1048576 KiB memory
mimalloc: using 5 numa regions
4
subproc 0
pages peak total current block total#
touched : 0 0 0 ok
pages : 717 1.2 K 702
abandoned : 550 839 275
reclaima : 0
reclaimf : 558
reabandon : 0
waits : 0
extended : 0
retire : 0
searches : 2.4 avg
arenas peak total current block total#
reserved : 1.0 GiB 1.0 GiB 1.0 GiB
committed : 191.5 MiB 191.5 MiB 191.5 MiB
reset : 0
purged : 2.6 MiB
arenas : 1
rollback : 0
mmaps : 4
commits : 0
resets : 0
purges : 37
guarded : 0
theaps : 1 1 1
heaps : 1 1 1
heap waits: 0
process peak total current block total#
threads : 2 2 2
numa nodes: 5
elapsed : 0.214 s
process : user: 0.158 s, system: 0.055 s, faults: 0, peak rss: 186.9 MiB, peak commit: 191.5 MiB
mimalloc: process done: 0x7FD95A8E2000
Again, maybe this is completely normal and expected, and users should turn MIMALLOC_ALLOW_THP off for short-lived processes, or turn THP in their system off if they don't want to deal with this kind of overhead.
Maybe mimalloc can submit some kind of madvise or something when it sets up a freelist of pages since that's what looks to be the cause of a lot of work on linux's MM?
here's the kernel portion of one of the perf recordings btw:
Thanks in advance for any advice!
TL;DR: This may just be the expected result of working with THP, that you pay for the management and receive benefits over the long run, but I was still rather surprised.
All of this is with v3.4.4 of mimalloc
You can see the disparity in system time is about 190 milliseconds and the range of wall-clock time is 14ms wide with THP turned off, and 130ms wide with THP turned on.
I made a perf recording of my program once with THP allowed and once with THP disallowed with this command:
env MVM_SPESH_BLOCKING=0 MIMALLOC_ALLOW_THP=1 perf record --user-callchains -g --call-graph fp -F 20000 rakudo -ne 'BEGIN my %h; if /\s+(\d+)/ { %h{$0}++; }; last if $++ > 1000; END say +%h.keys' gen/moar/CORE.*settingThe results in the report are rather large, so I collapsed them. Takeaway is that with THP on it recorded 9k samples for the same workload that recorded 4k samples without THP.
Results of a perf recording with THP disallowed and one with THP allowed:
command for the reports:
perf report --stdio --parent "mi_page_queue_find_free_ex" --exclude-other --sort=parentand here the same setup but with THP allowed
I only noticed this because there was a suspiciously long period on the bytecode specializer thread in MoarVM when it did its first batch of work, and for that period, basically 90% of samples were under
mi_theap_realloc_zerogoing all the way intomi_block_set_nextxwhich had all of theselfsamples;mi_block_set_nextxis highlighted in darker yellow in the following two pictures:and there were similar measurements the next time it worked:
these were recorded with the bytecode specializer thread in "blocking" mode, so that the thread that submitted the work waits for it to complete. that's why the main thread doesn't wake up at all during that time.
The main thread also has a few samples with
mi_block_set_nextxbut very noticeably less.It makes sense for the workload in question where the main thread really only works with small arrays and hashes that die young, so memory usage wouldn't grow, whereas the specializer thread would be doing different-sized amounts of work for each bytecode frame it encounters, including potentially quite large temporary arrays.
running the workload with MIMALLOC_VERBOSE=1 MIMALLOC_SHOW_STATS=1 and THP allowed and disallowed. the output looks almost the same, but one has 1 fault to the other's 0, and of course the time taken for the THP allowed one is a bit longer by roughly 200ms system time
allowed:
disallowed:
Again, maybe this is completely normal and expected, and users should turn MIMALLOC_ALLOW_THP off for short-lived processes, or turn THP in their system off if they don't want to deal with this kind of overhead.
Maybe mimalloc can submit some kind of
madviseor something when it sets up a freelist of pages since that's what looks to be the cause of a lot of work on linux's MM?here's the kernel portion of one of the perf recordings btw:
Thanks in advance for any advice!