Gists are flat, so directory paths are encoded with
@in the file names. Runsh unpack.shto restore the tree (pico/,src/,tools/), then follow the quick start below. Theresults@*files are the data set the PR comment quotes.
Compares picolibc's singly linked free list against the skip list from
PR #1369, plus a few
proposed fixes, on an RV64 Linux target. The allocator sources are
picolibc's own; the only delta is local.patch.
Reports and result sets live outside this directory. The suite produces
a CSV; tools/report.py turns it into tables.
Three machines, and nothing is built on the one you drive it from:
| role | |
|---|---|
| workstation | drives the tools, holds the sources, talks adb to the target |
| build host | cross-compiles with riscv64-unknown-linux-gnu-gcc; pick it with HOST |
| RV64 target | runs the binaries, reached over adb shell |
Binaries are static, so the target needs only a kernel and a writable
/tmp. Developed against a Spacemit Keystone K3
board (X100, RV64GCV, 8 cores, 64 KiB L1D, 4 MiB L2, no L3).
./tools/build.sh # sync -> build host -> cross-build -> adb push
./tools/check.sh # randomized correctness check on target
./tools/env.sh > env.txt # target and toolchain facts
./tools/run.sh > results.csv # sweep on target
python3 tools/report.py results.csv > tables.md
ssh "$HOST" "cd $REMOTE && make sizes" # code size + memory constants
Short sweep, or a single number:
NS="64 1024 16384" ./tools/run.sh > results.csv
NS=65536 WORKLOAD=churn VARIANTS="skip skip-fast" ./tools/run.sh
Overrides: HOST, REMOTE (build host), DEV_DIR (target directory),
EXEC, BINDIR (run somewhere other than adb), NS, SIZES, PIN,
WORKLOAD, VARIANTS. Build knobs: make TRIALS=n, make LTO=1.
pico/ holds malloc.c, free.c, realloc.c, malloc-skip.c,
local-malloc.h and malloc-skip.h copied straight from
libc/stdlib/, with local.patch applied on top. src/shim.h supplies
the picolibc internals the sources expect (__align_up,
__strong_reference, __LIBC_LOCK, picolibc's random() LCG), and
src/sys/lock.h is a no-op lock. sbrk is a bump pointer over a
pre-faulted 512 MiB mmap region (src/arena.c), so page faults stay
out of the measurements and the heap can be reset between trials. The
public entry points are renamed with -Dmalloc=__pico_malloc and
friends, so the C library's own allocator is untouched.
To refresh against a newer picolibc:
./extract.sh /path/to/picolibc # re-copy + re-apply local.patch
./extract.sh -r /path/to/picolibc # regenerate local.patch after editing pico/
local.patch contains exactly three things: the BENCH_STEP() counters,
MALLOC_SKIP_FASTSCAN, and MALLOC_SKIP_TOP. All are #ifdef-guarded,
so list and skip build unmodified upstream behaviour.
| name | what it is |
|---|---|
list |
upstream singly linked, address-ordered free list |
skip |
PR #1369 as posted |
skip-fast |
+ first-fit scan does not maintain prev[]; one _ms_find() once a chunk wins |
skip-top |
+ _ms_find() starts at the highest populated level instead of MS_MAX_LEVEL |
skip-fast-top |
both fixes |
list-scan, skip-scan |
the same scan change expressed through the free-list abstraction, so one loop serves both designs |
skip-lvl |
PR with the #ifdef __MALLOC_SKIP_LIST__ typo fixed, so that block compiles |
*-b512 |
as above with -Dmalloc-small-bucket=512 |
Variants live in one place, the VARIANTS list in the Makefile; the
tools read it back with make -s print-variants. To add one, add a name
and a <name>_DEF line.
Each variant builds three binaries: bench-* (timing), count-* (same
plus free-list pointer-chase counters, which would perturb the timings)
and check-* (correctness).
All start from the same state: 2N chunks allocated back to back, every
other one freed. The live chunks in between stop coalescing, so the free
list really holds N entries -- that is the length both designs have to
search.
| workload | what it measures |
|---|---|
free_rand |
the N frees in random address order |
free_asc |
ascending address order (worst case for the sorted list) |
free_desc |
descending, i.e. LIFO -- the common real pattern |
malloc_miss |
allocations too big for any hole: pure first-fit scan |
churn |
steady-state random alloc/free against the fragmented heap |
realloc_grow |
repeated growth; each call must locate the neighbouring chunk |
Per operation the CSV reports median nanoseconds, the spread across trials, free-list steps, heap high-water and the resulting free-list length.
- Every variant is the same source built with the same flags; only
-Ddiffers. No LTO by default, because picolibc does not use it -- which means upstream's linked-list helpers inline fromlocal-malloc.hwhile the skip-list ones stay out of line inmalloc-skip.c. That asymmetry is upstream's, not the harness's;make LTO=1measures how much of it matters. - Variants run back to back for each
(N, size), on the same pinned core, from the same RNG seed, so they see identical workloads. - Reported time is the median of 5 trials, and the spread between
the fastest and slowest trial travels with it.
tools/report.pymarks any cell that varied by more than 20%. A minimum would look sharper and hide exactly the cases where the machine, not the allocator, decided the result. - Each trial starts the heap at a different offset inside the arena
(
HEAP_STRIDEinsrc/arena.c). All trials in a process share one mapping, so without this they would inherit one physical page layout and its cache colouring would bias every trial of that variant the same way. The offset sequence is deterministic and identical across variants. malloc_missrequests a size above any small-bucket threshold, so the bucket variants walk the general free list too, instead of answering from a bucket and turning the comparison into list-walk versus linked-list pop.- The step counters count one pointer touch each, in both designs: one
per node for the linked list, one per level for the skip list's
_ms_stepand_ms_find. Counting a 16-levelprev[]update as a single step would flatter the skip list. count-*binaries run a single trial, since step counts are deterministic, and their timings are ignored bytools/report.py.
What is not controlled, and shows up as spread: DRAM refresh, other load
on the target, and the arena's absolute physical placement.
tools/env.sh records the governor and clock so a result set says what
it was measured on. The reference target runs the userspace governor
at a fixed 2.2 GHz.
Two things the harness deliberately does not equalize, because they are properties of the designs and not of the measurement:
MALLOC_SPLIT_MINdiffers (16 vs 144 bytes on LP64), so the two allocators make different splitting decisions and end up with slightly different free-list lengths for the same workload. The CSV reportsfree_lenso this is visible rather than assumed away.- picolibc's
malloc()zeroes every allocation. That cost is in both variants and dominates at large payloads, which is why the default sweep uses 32-byte payloads.