Skip to content

Per-pool interval watermarks, and cpu load repairs - #232

Open
TurkeyMan wants to merge 5 commits into
masterfrom
ow/mem-cpu-stats
Open

Per-pool interval watermarks, and cpu load repairs#232
TurkeyMan wants to merge 5 commits into
masterfrom
ow/mem-cpu-stats

Conversation

@TurkeyMan

@TurkeyMan TurkeyMan commented Aug 17, 2026

Copy link
Copy Markdown
Member

Independent of #224 -- this branches from master and does not need the page pool or the reclaim registry.

Groundwork for a system device in openwatt (open-watt/openwatt#532) that shows what memory and CPU are doing over time, so a small target's slide towards an out-of-memory death is visible before it arrives.

Interval watermarks

Sampling used once a second sees the level at that instant and nothing else. Both shapes that precede an OOM death go unseen: the transient spike that nearly exhausted a pool, and the floor creeping up underneath it.

So every alloc and free nudges its pool's low/high pair, and sample_pool_usage() reads the pair and re-arms both to the latest level. Each interval then reports the extremes reached within it. One sampler per pool, since reading consumes the interval. Note and sample race only against each other's precision, and a lost update costs one sample of resolution, which does not justify a CAS loop on the allocation path.

State lives in a new urt.mem.pressure, self-contained. Each platform feeds it from the truest source it has cheaply:

target source
Bouffalo exact: per-pool used is already maintained beside the TLSF pools, so the nudge is two compares, and TLSF sees every allocation including vendor C's through the malloc overrides
ESP32 total - heap_caps_get_free_size(), deliberately not a counter of our own: WiFi and lwIP allocate without passing through urt and they are exactly the pressure worth watching. Pool totals are cached so a chip with no PSRAM never walks the region list for an empty pool
elsewhere a running total kept in urt.mem.pressure alongside the watermarks, because nothing else counts allocations on those platforms

Drivers declare has_pool_usage; those that do not track their own pools ride a fallback hook in urt.mem.alloc, which compiles to nothing on the ones that do.

When #224 lands it brings urt.mem.reclaim, whose threat watermarks keep a running allocation total of their own. Whichever merges second should drop one of the two counters; they are both a single atomic on the same path.

CPU load

Going to report CPU the same way turned up three defects in count_system_load, each fixed in its own commit.

1. The ring is not advanced past busy time. It stepped by (b >> shift) - (a >> shift), the span of the idle interval alone, but g_bucket tracks where the previous wake left off. The busy stretch in between can cross a boundary by itself, and when it does both ends of the new idle span sit in the same later bucket, the step count comes out zero, and the idle is credited to a bucket that closed a while ago:

a=212500(bkt 3) b=250000(bkt 3) fi=0 g_bucket=3 -> cpu[3]=40892
a=262500(bkt 4) b=300000(bkt 4) fi=0 g_bucket=3 -> cpu[3]=78392   <-- capacity is 65536

get_cpu_load then sums more idle than the window holds and total_time - idle_time underflows. Now the ring rolls to where the idle span begins first, filling everything crossed on the way with zero, because none of that time was idle.

2. The stamps wrap. They were truncated to size_t, so on a 32-bit target nanoseconds wrap every 4.3s and the step count is usually garbage. That is what masked defect 1: the count gets clamped and resets the whole ring instead. Bucket numbers are 64-bit now; they are only shifted and compared, so a 32-bit target pays nothing for the width.

3. The percentage overflowed. total_time was about 1.0e9, so cpu_time * 100 left 32 bits above roughly 4% load:

true load   4% -> 4%      true load  50% -> 3%
true load   5% -> 0%      true load 100% -> 1%

Widening the multiply would fix it but costs a libcall on rv32. Decimating to microsecond buckets is better, and is what the second commit does: a whole 16-bucket window is then under 1e6, the load calculation stays a 32-bit multiply and divide, and the resolution given up is 1us against a 65ms bucket.

With those out of the way, get_cpu_load_range() adds the same low/high treatment memory gets. The average flattens exactly the bursts worth seeing: one saturated bucket inside an otherwise quiet second reads as 14% spread across the ring, when the busiest slice in it was 80%.

Verified

  • Windows and Linux (WSL) builds, all unit tests green.
  • New tests: watermark arm/sample/re-arm and the never-tracked case; and for CPU, a duty cycle whose busy stretch crosses bucket boundaries, the fully-busy and fully-idle ends, a run straddling the 32-bit microsecond wrap, and a saturated bucket the average hides. count_system_load reads the clock itself, so the body is split out as account_idle taking both stamps, which is what the tests drive.
  • Load accounting checked against a simulation at 1%/25%/50%/90% duty over 20Hz, 1kHz and 3Hz loops: reported within 1% of true in every case, no bucket ever over capacity, correct either side of the 32-bit wrap.
  • Semantic-checked with LDC for Espressif, and for BL808_M0, BL808 and BL618.

@TurkeyMan TurkeyMan changed the title Per-pool interval watermarks, and a cpu load range Per-pool interval watermarks, and cpu load repairs Aug 17, 2026
The ring was stepped by (b >> shift) - (a >> shift), the span of the idle
interval alone. But g_bucket tracks where the *previous* wake left off, and the
busy stretch between that wake and `a` can cross a bucket boundary on its own.
When it does, both ends of the new idle span land in the same later bucket, the
step count comes out zero, and the idle is added to a bucket that closed a while
ago -- pushing it past cpu_bucket_len:

  a=212500(bkt 3) b=250000(bkt 3) fi=0 g_bucket=3 -> cpu[3]=40892
  a=262500(bkt 4) b=300000(bkt 4) fi=0 g_bucket=3 -> cpu[3]=78392   <-- cap is 65536

get_cpu_load then sums more idle than the window holds and total_time - idle_time
underflows, so the percentage is nonsense. Today that is masked: the nanosecond
stamps are truncated to size_t, which on a 32-bit target wraps every 4.3s, so the
step count is usually garbage, gets clamped, and resets the whole ring instead.

Roll to the bucket the idle span starts in first, filling everything crossed on
the way with zero, because none of that time was idle. Then credit the span
itself. Bucket numbers move to 64 bits so they never wrap; they are only shifted
and compared, so a 32-bit target pays nothing for the width.
Nanosecond buckets put total_time at cpu_bucket_len * 15, about 1.0e9, so
cpu_time * 100 overflowed 32 bits above roughly 4% load and wrapped -- a fully
loaded system reported 1%, which is why that figure always looked implausibly
calm:

  true load   4% -> 4%      true load  50% -> 3%
  true load   5% -> 0%      true load 100% -> 1%

Widening the multiply would fix it but costs a libcall on rv32. Decimating to
microseconds is better: a whole 16-bucket window is then under 1e6, the load
calculation stays a 32-bit multiply and divide, and the resolution given up is
1us against a 65ms bucket. Bucket length becomes 0x1_0000, so the window is
1.049s rather than 1.074s.
Sampling `used` once a second sees the level at the sample instant and nothing
else, so a transient spike that nearly exhausted a pool and a floor creeping up
underneath it both pass unnoticed. That is precisely the pair of shapes that
precedes an out-of-memory death on a small target.

Every alloc and free now nudges its pool's low/high pair; a sampler reads the
pair and re-arms both to the latest level, so each interval reports the extremes
reached within it. One sampler per pool: a second reader steals the first's
interval. Note and sample race only against each other's precision, and a lost
update costs one sample of resolution, which does not justify a CAS loop on the
allocation path.

Each platform feeds the watermarks from the truest source it has cheaply:

  Bouffalo  exact. Per-pool `used` is already maintained beside the TLSF pools,
            so the nudge is two compares, and TLSF sees every allocation
            including the ones vendor C makes through the malloc overrides.
  ESP32     total minus heap_caps_get_free_size, deliberately not a counter of
            our own. WiFi and lwIP allocate without passing through urt and they
            are exactly the pressure worth watching. Pool totals are cached, so
            a chip with no PSRAM does not walk the region list for an empty pool.
  elsewhere a running total kept in urt.mem.pressure alongside the watermarks,
            because nothing else counts allocations on those platforms.

Drivers declare has_pool_usage; those that do not track their own pools ride the
fallback hook in urt.mem.alloc, which compiles to nothing on the ones that do.
get_cpu_load() averages all sixteen buckets, which flattens exactly the bursts
worth seeing: one bucket saturated inside an otherwise quiet second reads as 14%
once spread across the ring, when the busiest slice in it was 80%. Report the
quietest and busiest completed bucket alongside the average, giving cpu the same
low/high treatment memory now gets.

Microsecond buckets keep this in 32-bit arithmetic too: the widest term is
cpu_bucket_len * 100, well inside a uint.
The arithmetic here is fiddly enough that all three defects above sat in it
undetected, so pin it down. count_system_load reads the clock itself, which
leaves nothing to assert against; split the body out as account_idle taking both
stamps, and drive that from the tests.

Covers a duty cycle whose busy stretch crosses bucket boundaries (the case that
over-filled buckets), the fully-busy and fully-idle ends, a run straddling the
point where a 32-bit microsecond stamp would wrap, and a single saturated bucket
that the average hides but the range reports.
@TurkeyMan
TurkeyMan changed the base branch from ow/page-pool to master August 17, 2026 10:51
@TurkeyMan TurkeyMan closed this Aug 17, 2026
@TurkeyMan TurkeyMan reopened this Aug 17, 2026
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.

1 participant