Skip to content

amy_sysclock(): integer math - fix clock wrap at ~25 h and precision decay; drop per-check 64-bit divide in the sequencer#827

Open
rt-rtos wants to merge 1 commit into
shorepine:mainfrom
rt-rtos:fix-sysclock-integer-math
Open

amy_sysclock(): integer math - fix clock wrap at ~25 h and precision decay; drop per-check 64-bit divide in the sequencer#827
rt-rtos wants to merge 1 commit into
shorepine:mainfrom
rt-rtos:fix-sysclock-integer-math

Conversation

@rt-rtos

@rt-rtos rt-rtos commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Feedback wanted: Long term drift not measured on HW. Do we want a 24h+ A/B drift test as verification?

Summary

amy_sysclock() computes milliseconds-since-start through single-precision
float. That has two correctness problems that show up on long uptimes, plus
avoidable per-callback cost in the sequencer timer path on 32-bit targets.
This PR makes the clock exact integer math and tightens the sequencer's
tick-deadline check. Behavior is otherwise unchanged; the sequencer tick
rate is identical.

Bug 1: the clock wraps at ~24.9 hours at 48 kHz, not 49.7 days

return (uint32_t)((amy_global.total_blocks * AMY_BLOCK_SIZE / (float)AMY_SAMPLE_RATE) * 1000);

total_blocks is uint32_t and AMY_BLOCK_SIZE is an int constant, so
total_blocks * AMY_BLOCK_SIZE is computed in 32-bit integer arithmetic and
wraps at 2^32 samples - 89478 s (24.85 h) at 48 kHz, ~27 h at 44.1 kHz -
before the float conversion ever sees it. The comment ("uint32_t rollover is
49.7 days") describes the intended ms-domain wrap; the actual wrap comes ~48x
sooner. When it hits, the clock jumps back to near zero, and the sequencer's
1-second catch-up guard re-anchors: pending absolute-tick events are
re-evaluated against a clock that went backwards.

Bug 2: float precision quantizes the clock as uptime grows

The sample count passes through float (24-bit mantissa). Once total samples
exceed 2^24 (~6 min at 48 kHz) the conversion is no longer exact, and the
error grows with uptime. Measured with the test program below (48 kHz,
256-sample blocks): by 12 h of uptime the returned clock advances in 8 ms
jumps, while the true per-block step is 5.33 ms. Everything slaved to
amy_sysclock() - most importantly the sequencer, i.e. every scheduled
event - inherits that granularity.

Fix

uint32_t amy_sysclock() {
    // Time is returned in integer milliseconds; wraps at 2^32 ms = 49.7 days.
    // Integer math: computing this through float quantizes the clock once
    // total samples exceed the 24-bit mantissa (~6 min at 48 kHz), and the
    // u32 samples-domain multiply wrapped after 2^32 samples (~25 h).
    return (uint32_t)(((uint64_t)amy_global.total_blocks * (AMY_BLOCK_SIZE * 1000u)) / AMY_SAMPLE_RATE);
}

Exact for the full total_blocks range (verified against 128-bit reference
math: zero mismatches over the sampled u32 range including the wrap
boundary), monotonic, and the u32 result now genuinely wraps at 2^32 ms =
49.7 days as the original comment intended.

Sequencer: compare tick deadlines in the us domain

sequencer_check_and_fill() ran, per check:

while(amy_sysclock() >= (uint32_t)(amy_global.next_amy_tick_us / 1000L)) {

That is a second amy_sysclock() call plus a 64-bit divide per loop-condition
evaluation. GCC cannot strength-reduce a 64-bit divide by a constant on
32-bit targets, so on ESP32/RP2040/RP2350 the divide is a real __udivdi3
libcall executed on every 500 us timer callback. The function already
computes now_us at the top; comparing in the us domain removes both:

while(now_us >= amy_global.next_amy_tick_us) {

Behavior note: the old compare rounded the tick deadline down to a ms
boundary, so a tick could fire up to 1 ms early relative to its us
deadline; now it fires at the first 500 us callback at-or-after the deadline
(up to 1 ms later than before). next_amy_tick_us accumulates exactly as
before, so the long-term tick rate is bit-identical. Not re-sampling the
clock inside the catch-up loop is also safe: the existing 1-second skip-ahead
guard already bounds how much catch-up a single callback performs.

Sequencer: keep tempo math in single precision

sequencer_recompute() used unsuffixed double literals:

amy_global.us_per_tick = (uint32_t) (1000000.0 / ((amy_global.tempo/60.0) * (float)AMY_SEQUENCER_PPQ));

which promotes the whole expression to double - software-emulated on these
targets (__extendsfdf2 / __divdf3 / __muldf3 / __fixunsdfsi in the
disassembly). Algebraically identical single-divide float form:

amy_global.us_per_tick = (uint32_t) (60000000.0f / (amy_global.tempo * (float)AMY_SEQUENCER_PPQ));

Cold path (tempo changes only); included for hygiene since it is the same
clock code.

Measured effect (ESP32-S3, gcc 15.2, -O2 + LTO)

The 2 kHz sequencer timer callback goes from 77 instructions with __divsf3
(float divide inside the inlined amy_sysclock()) plus __udivdi3 per
deadline check, to 53 instructions with a single __udivdi3 (the new
sysclock's divide-by-constant) and no float conversion traffic. Small in
absolute CPU, but it runs on the shared timer-dispatch task; the correctness
fixes are the point.

Verification

python -m amy.test renders identically before and after this change
(same pass/fail set, same per-test error dB), as expected: offline test
renders never reach the uptimes where the float clock misbehaves.

Host-side check of old vs new (48 kHz / 256-sample blocks), comparing both
against 128-bit integer reference math:

new-vs-ref mismatches: 0

   hours       blocks       old_ms       new_ms old_err_ms
    0.10        67500       360000       360000          0
    1.00       675000      3600000      3600000          0
    6.00      4050000     21600000     21600000          0
   12.00      8100000     43200000     43200000          0
   24.00     16200000     86400000     86400000          0
   24.85     16773750     89460000     89460000          0
   25.00     16875000       521514     90000000  -89478486
   48.00     32400000     83321512    172800000  -89478488

old clock max step at 12h uptime: 8 ms (true block step: 5.33 ms)
Verification program source (gcc -O2 -o sysclock_check sysclock_check.c)
/* Host-side check: old float amy_sysclock() vs new integer version.
 * Mirrors the exact expressions; AMY_BLOCK_SIZE=256, AMY_SAMPLE_RATE=48000.
 */
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

#define AMY_BLOCK_SIZE 256
#define AMY_SAMPLE_RATE 48000

static uint32_t sysclock_old(uint32_t total_blocks) {
    return (uint32_t)((total_blocks * AMY_BLOCK_SIZE / (float)AMY_SAMPLE_RATE) * 1000);
}
static uint32_t sysclock_new(uint32_t total_blocks) {
    return (uint32_t)(((uint64_t)total_blocks * (AMY_BLOCK_SIZE * 1000u)) / AMY_SAMPLE_RATE);
}
/* Reference: exact ms via wide integer math (ground truth). */
static uint32_t sysclock_ref(uint32_t total_blocks) {
    return (uint32_t)((unsigned __int128)total_blocks * AMY_BLOCK_SIZE * 1000 / AMY_SAMPLE_RATE);
}

int main(void) {
    const double blocks_per_sec = (double)AMY_SAMPLE_RATE / AMY_BLOCK_SIZE; /* 187.5 */

    /* 1. new == ref everywhere (sampled densely incl. boundaries) */
    uint64_t mismatches = 0;
    for (uint64_t tb = 0; tb <= 0xFFFFFFFFULL; tb += 991) /* prime stride, ~4.3M samples */
        if (sysclock_new((uint32_t)tb) != sysclock_ref((uint32_t)tb)) mismatches++;
    for (uint64_t tb = 0xFFFFFFFFULL - 1000; tb <= 0xFFFFFFFFULL; tb++)
        if (sysclock_new((uint32_t)tb) != sysclock_ref((uint32_t)tb)) mismatches++;
    printf("new-vs-ref mismatches: %" PRIu64 "\n", mismatches);

    /* 2. old-version quantization + wrap at representative uptimes */
    const double hours[] = {0.1, 1, 6, 12, 24, 24.85, 25, 48};
    printf("\n%8s %12s %12s %12s %10s\n", "hours", "blocks", "old_ms", "new_ms", "old_err_ms");
    for (unsigned i = 0; i < sizeof(hours)/sizeof(hours[0]); i++) {
        uint32_t tb = (uint32_t)(hours[i] * 3600 * blocks_per_sec);
        uint32_t o = sysclock_old(tb), n = sysclock_new(tb);
        printf("%8.2f %12u %12u %12u %10.0f\n", hours[i], tb, o, n, (double)o - (double)n);
    }

    /* 3. old-version step size (clock granularity) around 12 h uptime */
    uint32_t tb12 = (uint32_t)(12 * 3600 * blocks_per_sec);
    uint32_t prev = sysclock_old(tb12), maxstep = 0;
    for (uint32_t tb = tb12 + 1; tb < tb12 + 2000; tb++) {
        uint32_t v = sysclock_old(tb);
        if (v != prev && v - prev > maxstep) maxstep = v - prev;
        prev = v;
    }
    printf("\nold clock max step at 12h uptime: %u ms (true block step: 5.33 ms)\n", maxstep);
    return 0;
}

…decay

amy_sysclock() computed ms-since-start through single-precision float,
with two correctness problems on long uptimes:

- total_blocks * AMY_BLOCK_SIZE is a u32 multiply that wraps at 2^32
  samples (~24.9 h at 48 kHz), long before the intended 49.7-day
  ms-domain wrap the comment describes. When it hits, the clock jumps
  back near zero and the sequencer re-anchors against a clock that went
  backwards.
- the float conversion quantizes the clock once total samples exceed
  the 24-bit mantissa (~6 min at 48 kHz); by 12 h uptime the returned
  clock advances in 8 ms jumps vs the true 5.33 ms block step.

Replace with exact 64-bit integer math (verified against wide-integer
reference math over the full u32 range, zero mismatches); the u32
result now genuinely wraps at 2^32 ms = 49.7 days.

In the sequencer, compare tick deadlines in the us domain: the old
ms-domain compare cost a second amy_sysclock() call plus a 64-bit
divide per loop-condition check - a real __udivdi3 libcall on 32-bit
targets, executed in every 500 us timer callback. next_amy_tick_us
accumulates exactly as before, so the long-term tick rate is
unchanged. Also keep sequencer_recompute()'s tempo math in single
precision; the unsuffixed double literals pulled software double
emulation into the build on 32-bit targets.
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