Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion doc/kernel/services/timing/clocks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ The default, :kconfig:option:`CONFIG_TIMEOUT_BACKEND_DLIST`, stores
events in a doubly linked list sorted by expiry, each holding a delta
count in ticks from its predecessor. Insertion is O(N) in the number of
pending timeouts: inexpensive for the handful a typical system has
pending, but it scales poorly when many are outstanding. The three
pending, but it scales poorly when many are outstanding. The four
alternative backends, all currently experimental, trade extra memory or
behaviour for faster insertion at scale:

Expand All @@ -228,6 +228,13 @@ behaviour for faster insertion at scale:
unlike the wheel it preserves same-tick firing order and adds no
idle-wakeup cost.

* :kconfig:option:`CONFIG_TIMEOUT_BACKEND_SKIPLIST` is a Pugh skip list
keyed on absolute expiry. Expected insertion and removal are O(log N)
with no capacity limit, and same-tick firing order is FIFO. It
requires 64-bit ticks. Each event stores
:kconfig:option:`CONFIG_TIMEOUT_SKIPLIST_MAX_LEVEL` forward pointers,
so per-event RAM is higher than the delta list or min-heap.

The non-default backends target systems that hold many concurrent
timeouts, especially ones clustered in the near future. For most
applications the delta list remains the appropriate default.
Expand Down
17 changes: 13 additions & 4 deletions include/zephyr/kernel_structs.h
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,21 @@ struct _timeout {
*/
int64_t abs_ticks;
struct min_heap_handle heap_handle;
#elif defined(CONFIG_TIMEOUT_BACKEND_SKIPLIST)
/*
* Skip-list backend: absolute expiry tick plus a geometric-height
* tower of forward pointers. height == 0 means the timeout is not
* queued (idle, popped for announcing, or aborted).
*/
int64_t abs_ticks;
uint8_t height;
struct _timeout *forward[CONFIG_TIMEOUT_SKIPLIST_MAX_LEVEL];
#else
/*
* Delta-list and timer-wheel backends: a list node plus dticks (a
* delta to the predecessor for the delta list; an encoded slot
* position for the wheel). The wheel adds a flags field recording
* which wheel tier the timeout currently occupies.
* Delta-list, bucket, and timer-wheel backends: a list node plus
* dticks (a delta to the predecessor for the delta list; an encoded
* slot position for the wheel or bucket). The wheel adds a flags
* field recording which wheel tier the timeout currently occupies.
*/
sys_dnode_t node;
#if defined(CONFIG_TIMEOUT_BACKEND_WHEEL)
Expand Down
29 changes: 29 additions & 0 deletions kernel/Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,22 @@ config TIMEOUT_BACKEND_BUCKET
is on demand against the actual next event, so it imposes no
tickless-idle floor. Requires 64-bit timeouts.

config TIMEOUT_BACKEND_SKIPLIST
bool "Skip list [EXPERIMENTAL]"
depends on TIMEOUT_64BIT
select EXPERIMENTAL
help
A Pugh skip list keyed on absolute expiry tick. Expected insertion
and arbitrary removal are O(log n), the earliest timeout is O(1),
and there is no capacity limit. Same-tick firing order is FIFO.
Each pending timeout carries CONFIG_TIMEOUT_SKIPLIST_MAX_LEVEL
forward pointers, sized for the worst case although the expected
height is 2. At the default 8 levels struct _timeout is 88 bytes
on a 64-bit target, against 32 for the delta list and 24 for the
min-heap, and every k_thread, k_timer and delayable work item
pays it. Requires 64-bit timeouts. Consider this when many
timeouts are pending and a bounded heap is undesirable.
Comment on lines +830 to +836

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth quantifying, since this is the main thing an integrator weighs against the min-heap, the other O(log n) option. Measured from DWARF on qemu_x86_64: struct _timeout is 88 bytes here against 32 for the delta list and 24 for the min-heap, and struct k_thread goes from 880 to 928. The gap is large because expected height is 2 while every node statically carries all 8 levels, so most of each tower is unused.

Suggested change
Each pending timeout carries CONFIG_TIMEOUT_SKIPLIST_MAX_LEVEL
forward pointers, so per-event RAM is higher than the delta list
or min-heap. Requires 64-bit timeouts. Consider this when many
timeouts are pending and a bounded heap is undesirable.
Each pending timeout carries CONFIG_TIMEOUT_SKIPLIST_MAX_LEVEL
forward pointers, sized for the worst case although the expected
height is 2. At the default 8 levels struct _timeout is 88 bytes
on a 64-bit target, against 32 for the delta list and 24 for the
min-heap, and every k_thread, k_timer and delayable work item
pays it. Requires 64-bit timeouts. Consider this when many
timeouts are pending and a bounded heap is undesirable.

@malto101 malto101 Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done partially. but will it make sense to keep these sizes in the Kconfig help, or should they live somewhere that can stay accurate?

The RAM cost is the real trade-off here, so a number is useful. The current wording mixes a 64-bit _timeout comparison (88 vs 32 dlist / 24 min-heap at 8 levels) with a k_thread figure that is qemu_x86_64-specific (880 -> 928). The thread delta is not even sizeof(_timeout): FXSAVE 16-byte alignment swallows 8 bytes, so Cortex-M sees the full +56.

If we keep numbers in Kconfig, I would rather quote only struct _timeout and drop the board-specific k_thread line. A one-line comparison in the docs (or next to TIMEOUT_SKIPLIST_MAX_LEVEL) would also age better than baking 880/928 into the choice help.


endchoice # TIMEOUT_BACKEND

config TIMEOUT_BUCKET_LISTS
Expand All @@ -834,6 +850,19 @@ config TIMEOUT_BUCKET_LISTS
Below 8 the plain delta-list backend is likely the better choice. The
occupancy bitmap is a uint32_t for up to 32 buckets, a uint64_t beyond.

config TIMEOUT_SKIPLIST_MAX_LEVEL
int "Maximum skip-list height"
depends on TIMEOUT_BACKEND_SKIPLIST
default 8
range 4 16
help
Maximum number of forward-pointer levels in each timeout node.
Height is drawn from a geometric(1/2) distribution, so expected
search cost is O(log n) as long as this is at least log2 of the
peak number of pending timeouts. Each extra level adds one pointer
to every struct _timeout (k_timer, k_thread, delayable work, ...).
8 levels cover a few hundred concurrent timeouts comfortably.

config TIMEOUT_HEAP_MAX_ENTRIES
int "Maximum simultaneous pending timeouts in the heap"
depends on TIMEOUT_BACKEND_MINHEAP
Expand Down
13 changes: 13 additions & 0 deletions kernel/include/timeout_q.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ static inline bool z_is_inactive_timeout(const struct _timeout *to)
return !sys_dnode_is_linked(&to->node);
}

#elif defined(CONFIG_TIMEOUT_BACKEND_SKIPLIST)

static inline void z_init_timeout(struct _timeout *to)
{
to->height = 0;
to->abs_ticks = 0;
}

static inline bool z_is_inactive_timeout(const struct _timeout *to)
{
return to->height == 0;
}

#else /* CONFIG_TIMEOUT_BACKEND_DLIST or _BUCKET */

static inline void z_init_timeout(struct _timeout *to)
Expand Down
2 changes: 2 additions & 0 deletions kernel/timeout.c
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ static uint32_t elapsed(void)
#include "timeout_wheel.h"
#elif defined(CONFIG_TIMEOUT_BACKEND_BUCKET)
#include "timeout_bucket.h"
#elif defined(CONFIG_TIMEOUT_BACKEND_SKIPLIST)
#include "timeout_skiplist.h"
#else /* CONFIG_TIMEOUT_BACKEND_DLIST */
#include "timeout_list.h"
#endif
Expand Down
231 changes: 231 additions & 0 deletions kernel/timeout_skiplist.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*
* Copyright (c) 2026 The Zephyr Project Contributors
*
* SPDX-License-Identifier: Apache-2.0
*/

#ifndef ZEPHYR_KERNEL_TIMEOUT_SKIPLIST_H_
#define ZEPHYR_KERNEL_TIMEOUT_SKIPLIST_H_

/**
* @file
* @brief Skip-list timeout backend (implementation).
*
* Pending timeouts are kept in a Pugh skip list keyed on absolute expiry
* tick (struct _timeout's abs_ticks). Expected insertion and arbitrary
* removal are O(log n); the earliest timeout is always the level-0 successor
* of the sentinel. A timeout that is not queued has height == 0.
*
* Same-tick firing order is FIFO: a new node is inserted after every node
* that already has the same abs_ticks.
*
* Node height is drawn from a geometric(1/2) distribution using a private
* xorshift32, so the announce/add/abort paths never call the entropy driver.
* There is no capacity limit (unlike the min-heap backend).
*/

#define SKIPLIST_LEVELS CONFIG_TIMEOUT_SKIPLIST_MAX_LEVEL

BUILD_ASSERT(SKIPLIST_LEVELS >= 4 && SKIPLIST_LEVELS <= 16,
"CONFIG_TIMEOUT_SKIPLIST_MAX_LEVEL must be in [4, 16]");

/* Sentinel: never expires, participates in every level, never fired. */
static struct _timeout sl_head = {
.height = SKIPLIST_LEVELS,
.forward = { NULL },
};

/* Highest height of any currently queued node; 0 when the list is empty. */
static uint8_t sl_top;

/* Marsaglia xorshift32 state; period 2^32-1, must stay non-zero. */
static uint32_t sl_rng = 2463534242U;

static uint8_t skiplist_random_height(void)
{
uint32_t r = sl_rng;

r ^= r << 13;
r ^= r >> 17;
r ^= r << 5;
sl_rng = r;

/* p = 1/2: height is 1 + the run of low 1-bits, capped at SKIPLIST_LEVELS. */
return 1 + MIN(u32_count_trailing_zeros(~r), SKIPLIST_LEVELS - 1);
}

static inline struct _timeout *skiplist_first(void)
{
return sl_head.forward[0];
}

static void skiplist_drop_top(void)
{
while (sl_top > 0 && sl_head.forward[sl_top - 1] == NULL) {
sl_top--;
}
}

static void skiplist_update_init(struct _timeout **update)
{
uint8_t i;

for (i = 0; i < SKIPLIST_LEVELS; i++) {
update[i] = &sl_head;
}
}

/* Splice @to out at every level it occupies. */
static void skiplist_unlink(struct _timeout *to, struct _timeout **update)
{
uint8_t i;
uint8_t height = to->height;

for (i = 0; i < height; i++) {
__ASSERT_NO_MSG(update[i]->forward[i] == to);
update[i]->forward[i] = to->forward[i];
}

to->height = 0;
skiplist_drop_top();
}

/* Equal keys are skipped so a later insert lands after them (same-tick FIFO). */
static void skiplist_predecessors_after(int64_t abs_ticks, struct _timeout **update)
{
struct _timeout *x = &sl_head;
int i;

for (i = (int)sl_top - 1; i >= 0; i--) {
while (x->forward[i] != NULL && x->forward[i]->abs_ticks <= abs_ticks) {
x = x->forward[i];
}
update[i] = x;
}
}

/* Walk equal-key nodes so identity, not just expiry, selects the splice points. */
static void skiplist_predecessors_of(struct _timeout *to, struct _timeout **update)
{
struct _timeout *x = &sl_head;
int i;

for (i = (int)sl_top - 1; i >= 0; i--) {
while (x->forward[i] != NULL && x->forward[i]->abs_ticks < to->abs_ticks) {
x = x->forward[i];
}
update[i] = x;
}

for (i = 0; i < to->height; i++) {
while (update[i]->forward[i] != NULL &&
update[i]->forward[i] != to &&
update[i]->forward[i]->abs_ticks == to->abs_ticks) {
update[i] = update[i]->forward[i];
}
}
}

static inline bool z_timeout_q_insert(struct _timeout *to, k_ticks_t dticks)
{
struct _timeout *update[SKIPLIST_LEVELS];
uint8_t i;
uint8_t height;

to->abs_ticks = (int64_t)curr_tick + dticks;
height = skiplist_random_height();
to->height = height;

skiplist_update_init(update);
skiplist_predecessors_after(to->abs_ticks, update);

if (height > sl_top) {
sl_top = height;
}

for (i = 0; i < height; i++) {
to->forward[i] = update[i]->forward[i];
update[i]->forward[i] = to;
}

return skiplist_first() == to;
}

static inline bool z_timeout_q_remove(struct _timeout *to)
{
struct _timeout *update[SKIPLIST_LEVELS];
bool was_first = (skiplist_first() == to);

__ASSERT_NO_MSG(to->height > 0);

skiplist_update_init(update);
skiplist_predecessors_of(to, update);

skiplist_unlink(to, update);
Comment on lines +156 to +164

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop's only body is an assertion. With CONFIG_ASSERT=n, __ASSERT_NO_MSG(test) expands to { } and the test expression is discarded by the preprocessor, leaving an empty bounded loop. At -Os it is optimized away, but under CONFIG_NO_OPTIMIZATIONS=y it survives and runs up to to->height empty iterations while holding the timeout spinlock (measured: z_timeout_q_remove 112 to 83 bytes). Since the check is redundant with skiplist_unlink(), moving it there keeps the assertion and drops the loop, one pass and one branch per level.

Suggested change
struct _timeout *update[SKIPLIST_LEVELS];
bool was_first = (skiplist_first() == to);
uint8_t i;
__ASSERT_NO_MSG(to->height > 0);
skiplist_update_init(update);
skiplist_predecessors_of(to, update);
for (i = 0; i < to->height; i++) {
__ASSERT_NO_MSG(update[i]->forward[i] == to);
}
skiplist_unlink(to, update);
struct _timeout *update[SKIPLIST_LEVELS];
bool was_first = (skiplist_first() == to);
__ASSERT_NO_MSG(to->height > 0);
skiplist_update_init(update);
skiplist_predecessors_of(to, update);
skiplist_unlink(to, update);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


return was_first;
}

static inline k_ticks_t z_timeout_q_remainder(const struct _timeout *to)
{
return (k_ticks_t)(to->abs_ticks - (int64_t)curr_tick);
}

static inline k_ticks_t z_timeout_q_next_expiry(void)
{
struct _timeout *t = skiplist_first();
int64_t gap;

if (t == NULL) {
return K_TICKS_FOREVER;
}

gap = t->abs_ticks - (int64_t)curr_tick;
return (gap < 0) ? 0 : (k_ticks_t)gap;
}

static inline int32_t z_timeout_q_next_gap(void)
{
struct _timeout *t = skiplist_first();
int64_t gap;

if (t == NULL) {
return INT32_MAX;
}

/* Overdue must be 0: announce casts dt to uint32_t. */
gap = t->abs_ticks - (int64_t)curr_tick;
if (gap <= 0) {
return 0;
}

return (int32_t)MIN(gap, (int64_t)INT32_MAX);
}

static inline void z_timeout_q_advance(int32_t dt)
{
ARG_UNUSED(dt);
}

/* Earliest node, so it is the head's successor at every level it occupies. */
static inline struct _timeout *z_timeout_q_pop_due(void)
{
struct _timeout *t = skiplist_first();
uint8_t i;

if ((t == NULL) || (t->abs_ticks > (int64_t)curr_tick)) {
return NULL;
}

for (i = 0; i < t->height; i++) {
__ASSERT_NO_MSG(sl_head.forward[i] == t);
sl_head.forward[i] = t->forward[i];
}

t->height = 0;
skiplist_drop_top();

return t;
}

#endif /* ZEPHYR_KERNEL_TIMEOUT_SKIPLIST_H_ */
6 changes: 6 additions & 0 deletions tests/kernel/timeout/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ tests:
tags:
- kernel
- timer
kernel.timer.timeout.skiplist:
extra_configs:
- CONFIG_TIMEOUT_BACKEND_SKIPLIST=y
tags:
- kernel
- timer
Loading