Skip to content

Add a compressed swap (zram) toggle and status row to the settings UI - #873

Open
yuzi-co wants to merge 1 commit into
sipeed:mainfrom
yuzi-co:feat/zram-swap
Open

Add a compressed swap (zram) toggle and status row to the settings UI#873
yuzi-co wants to merge 1 commit into
sipeed:mainfrom
yuzi-co:feat/zram-swap

Conversation

@yuzi-co

@yuzi-co yuzi-co commented Aug 13, 2026

Copy link
Copy Markdown

The SG2002 board has ~158MB of usable RAM after the 75MB ION carveout, and the only swap the UI offers is a file on the boot SD card — the card that Settings > Swap warns will wear out. zram gives the same relief in compressed RAM, with no card wear at all.

zram already works on this hardware today, but only as a manual install: build the modules, copy an init script to /etc/init.d by hand. Nothing in the web UI reports it, so an operator cannot tell whether compressed swap is running or how close it is to its memory cap.

This adds one row to Settings > Device > Advanced.

Three booleans, not one flag

The row reports available, enabled and active separately:

field means
available the kernel modules are present
enabled /etc/init.d/S01zram is installed, so the setting survives a reboot
active /dev/zram0 is in /proc/swaps right now

"Enabled but not active" is the state that matters most. A single flag renders it as plain "off", which invites the operator to toggle it on and watch nothing happen. The row shows it in amber and says so.

When zram is running the row also reports the algorithm, the compression ratio, memory used against the memory cap, and the system-wide pswpin/pswpout counters.

The kernel modules stay a manual step

zram and zsmalloc are not in the stock image — CONFIG_ZSMALLOC is unset, so zram needs both. They are built out of tree against the stock kernel and pinned to its vermagic, so shipping them inside the install package would mean reshipping them whenever the kernel moves. tools/zram/build-modules.sh builds them; tools/zram/README.md documents it.

Without the modules the toggle is disabled and states why. No dead control, no silent failure.

Why the server installs the init script

S01zram ships in kvmapp/system/init.d/ so the package carries it and the server has a source to copy from. It is deliberately not added to the copy list in system_init.cpp: that list is hard-coded C++, and adding a name there needs a MaixCDK rebuild plus a kvm_system redeploy on every device. The server owns the copy instead, the way S98tailscaled already treats presence in /etc/init.d as the installed marker.

A start that produces no device rolls the install back, so enabled never claims that a reboot will help.

Three defects fixed on the way

disableSwap ran swapoff -a. That stops every swap device, so any change to the swap file size stopped zram as a side effect — not what a control labelled "swap file size" should do. It now names the file, and skips the call entirely when that file is not swapped on. (swapoff fails on an inactive file; the -a form used to hide that by succeeding on some other device.)

S01zram did not reset a device that was already initialised. The script had only ever run at boot, where a freshly inserted module reports disksize 0. A toggle introduces a stop-then-start on a live device, and the kernel rejects the second disksize write:

+ echo 96M
sh: write error: Resource busy
+ echo 'FAIL (disksize)'

start now resets a device reporting a non-zero disksize. The caller has already established the device is not in /proc/swaps, so the reset cannot take swap away from anything.

S01zram set no swap priority. It now asks for -p 100 and falls back to a plain swapon, because a BusyBox applet built without FEATURE_SWAPON_PRI rejects the option — and this image's BusyBox 1.36.1 is such a build. Boot order does not compensate: the swap file is enabled from a si11::sysinit line and S01zram runs from the rcS wait entry, and BusyBox init runs every sysinit entry before any wait entry. So on this board the swap file always wins the better priority. The two controls stay independent, and tools/zram/README.md says why you should still pick one rather than enabling both.

API

GET  /api/vm/zram   -> {available, enabled, active, algorithm, diskSize,
                        original, compressed, memUsed, memLimit, swapIn, swapOut}
POST /api/vm/zram   <- {enabled}

Both sit behind the same auth middleware as the rest of /api/vm.

Testing

  • server/service/vm/zram_test.go and swap_test.go — the service reads and writes through injectable paths and a command seam, so the whole state machine is covered off-device, including the rollback path and every parse failure.
  • tools/zram/test-zram-swapon.sh and test-zram-reset.sh — on-device checks for the priority fallback and the stale-device reset.
  • Verified on hardware: the stale-device start reproduced the EBUSY failure and now reports OK.

Only en.ts carries the new strings; the other locales fall back to English until translated.

zram works on this board, but only as a manual install: build the kernel
modules, then copy an init script to /etc/init.d by hand. The web UI reports
nothing about it, so an operator cannot tell whether compressed swap runs, or
how close it is to its memory cap. This adds one row to Settings > Device >
Advanced that reports the state and switches it on or off.

The row reports three independent booleans rather than one flag. Available is
whether the kernel modules are present, enabled is whether the init script is
installed, and active is whether /dev/zram0 is in /proc/swaps. "Enabled but
not active" is the state that matters most, and a single flag would render it
as plain "off" -- inviting the operator to toggle it on and watch nothing
happen. The row shows it in amber instead.

The modules stay a manual step. zram and zsmalloc are built out of tree
against the stock kernel and pinned to its vermagic, so shipping them in the
install package would mean reshipping them whenever the kernel moves.
tools/zram/build-modules.sh builds both. Without them the toggle is disabled
and says so.

S01zram lives in kvmapp/system/init.d so the package carries it and the server
has a source to copy from. It is deliberately not added to the copy list in
system_init.cpp: that list is hard-coded C++, and a name added there needs a
MaixCDK rebuild plus a kvm_system redeploy on every device. The server owns
the copy, the way S98tailscaled already treats presence in /etc/init.d as the
installed marker. A start that produces no device rolls the install back, so
enabled never claims a reboot will help.

Three defects surfaced while wiring this up, and all three are fixed here.

disableSwap ran `swapoff -a`, which stops every swap device. Any change to the
swap file control therefore stopped zram as a side effect, which is not what a
control labelled "swap file size" should do. It now names the file, and skips
the call entirely when that file is not swapped on -- swapoff fails on an
inactive file, and the -a form used to hide that by succeeding on some other
device.

S01zram did not reset a device that was already initialised. The script only
ever ran at boot, where a freshly inserted module reports disksize 0. A toggle
introduces a stop-then-start on a live device, the kernel rejects the second
disksize write with EBUSY, and the start returns 1:

  + echo 96M
  sh: write error: Resource busy
  + echo 'FAIL (disksize)'

start now resets a device that reports a non-zero disksize. The caller has
already established the device is not in /proc/swaps, so the reset cannot take
swap away from anything.

S01zram did not set a swap priority. Both swaps are enabled during boot and
the order between them is undefined. It now asks for -p 100 and falls back to
a plain swapon, because a busybox applet built without FEATURE_SWAPON_PRI
rejects the option. This image's BusyBox 1.36.1 is such a build: swapon offers
only -a and -e, so neither -p nor a pri= in fstab can set a priority. The -p
attempt stays, because it is correct elsewhere and the fallback costs the
priority rather than the swap.

Boot order does not compensate for that. The swap file is enabled from a
si11::sysinit line and S01zram runs from the rcS wait entry, and BusyBox init
runs every sysinit entry before any wait entry. So the swap file is always
swapped on first and always takes the better priority. Enabling both on this
board means the kernel writes to the SD card before compressed RAM. The two
controls stay independent, and tools/zram/README.md says why you should still
pick one.

Verified on hardware: the stale-device start reproduced the EBUSY failure and
now reports OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uHXYqo7YSPMWy76LhjHfR
@lowtech-guy

Copy link
Copy Markdown
Contributor

SG2002 cpu is so weak, add zram only turn whole system into unusable.

@yuzi-co

yuzi-co commented Aug 14, 2026

Copy link
Copy Markdown
Author

Fair concern, so I measured it on the board rather than argue it. SG2002, hardware revision beta,
kernel 5.10.4, single C906 with clk_c906_0 reading 850 MHz.

The premise is right: there is almost no headroom during a session. With one MJPEG viewer
attached the board runs at 89 to 94% busy, and NanoKVM-Server takes 69 to 80% of the core on its
own. The cost is delivery rather than capture: softirq alone takes 28% of the core pushing 7 to
10 MB/s out of eth0, the same capture served as H.264 over the direct path puts the board at 24%,
and when the last viewer leaves capture stops and the board falls to 6 to 8%.

What zram costs on top of that, measured on a second device added with hot_add so the live swap
was untouched. Mean of three passes over 8 MB of program text; the copy row is a single pass:

compress decompress ratio
lzo-rle (this PR) 12.7 MB/s, 79 ms CPU/MB 41 MB/s, 26 ms CPU/MB 1.38×
zstd 3.0 MB/s, 337 ms CPU/MB 17 MB/s, 61 ms CPU/MB 1.82×
plain memory copy, no zram 114 MB/s, 8.8 ms CPU/MB n/a n/a

At rest it costs nothing: the codec runs on swap I/O and nothing else, and a 20 s idle sample moved
no pages. Over 36 hours of uptime this board swapped out 55.8 MB in total,
which at the rate above is about 4.4 CPU-seconds of compression.

Under load. One viewer at a time, with zram compressing 8 MB every 2 s without pause. Every
client runs off the board, so what a client costs itself is not in these numbers. The answer
depends entirely on which stream path the session uses:

MJPEG H.264 direct WebRTC (what the UI picks)
frame rate, quiet 24.1 fps 29.8 fps 30 fps
frame rate, bursting 20.6 fps (-14.5%) 29.5 fps (-1.0%) 30 fps
NanoKVM-Server, quiet 70.9% of core 11.3% 20.6%
NanoKVM-Server, bursting 61.6% 11.5% 21.8%
whole board, quiet 92.4% 23.7% 30.5%
whole board, bursting 95.8% 41.9% 50.2%
delivered, quiet 9.1 MB/s 0.17 MB/s 0.11 MB/s
delivered, bursting 7.8 MB/s 0.17 MB/s 0.11 MB/s

MJPEG loses frames, and it starts from a board already at 92% before zram runs at all. The session
does not stall and the rate recovers as soon as the bursts stop. Neither H.264 path moves: the
server stays where it was, delivery stays where it was, and half the core is still free while zram
runs without pause. What the burst does on those paths is take idle time, which is why the board
figure climbs while nothing about the session changes.

The difference is delivery, not capture. All three paths capture the same frames through the same
hardware encoder. MJPEG puts 9.1 MB/s on the wire at 1080p, which is 73 Mbit of a 100 Mbit link,
and softirq alone takes 28 to 35% of the core carrying it across my runs. The H.264 paths put
about 40x less on the wire, and their softirq is under 1%.

Two notes on method. An earlier version of this comment quoted an MJPEG run with the viewer on the
board itself, which flatters MJPEG because it skips the network path. The MJPEG and direct frame
rates are counted by the client, while the WebRTC row uses the board's own frame counter and the
board's eth0 byte counter, because a background browser tab does not present every frame it
receives and so cannot be trusted to count them.

The alternative is not free either. zram sits beside a swap-file control, and root here is a
loop image on the boot SD card: a 16 MB write with fsync measures 5.2 to 7.6 MB/s at 37 to 48 ms CPU/MB, plus card wear,
plus reclaim that depends on the loop device, which is the wrong dependency for the path that
relieves memory pressure.

One caveat I won't leave out: a memory-pressure test left the board unable to start any process
until a power cycle, with nothing killed that I could observe. zram held 14.4 MB of its 40 MB cap
at the last sample, so I cannot pin it on zram, and I have not tested an SD swap file under the
same pressure.

The feature is off unless an operator turns it on, and it cannot be turned on at all until someone
builds two kernel modules by hand, since CONFIG_ZSMALLOC is unset in the stock image. Nobody who
does not opt in pays anything for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants