diff --git a/.changeset/scheduled-bitset-matrix.md b/.changeset/scheduled-bitset-matrix.md new file mode 100644 index 000000000..f6b0da712 --- /dev/null +++ b/.changeset/scheduled-bitset-matrix.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/scheduled": minor +--- + +Add `SlotBitset128` branchless 128-bit BigInt scheduling slot collision matrix and `computePowerOfTwoMask` power-of-two ringbuffer allocator. diff --git a/packages/scheduled/src/bitset.ts b/packages/scheduled/src/bitset.ts new file mode 100644 index 000000000..4052436e3 --- /dev/null +++ b/packages/scheduled/src/bitset.ts @@ -0,0 +1,54 @@ +export function computePowerOfTwoMask(requestedCapacity: number): { + capacity: number; + mask: number; +} { + const capacity = 1 << (32 - Math.clz32(Math.max(requestedCapacity, 2) - 1)); + const mask = capacity - 1; + return { capacity, mask }; +} + +export class SlotBitset128 { + private mask: bigint; + + constructor(initialMask: bigint = 0n) { + this.mask = initialMask & ((1n << 128n) - 1n); + } + + get rawMask(): bigint { + return this.mask; + } + + occupySlot(slotIndex: number): void { + if (slotIndex < 0 || slotIndex >= 128) return; + this.mask |= 1n << BigInt(slotIndex); + } + + freeSlot(slotIndex: number): void { + if (slotIndex < 0 || slotIndex >= 128) return; + this.mask &= ~(1n << BigInt(slotIndex)); + } + + isSlotOccupied(slotIndex: number): boolean { + if (slotIndex < 0 || slotIndex >= 128) return true; + return (this.mask & (1n << BigInt(slotIndex))) !== 0n; + } + + static createSpanMask(startSlot: number, slotCount: number): bigint { + if (slotCount <= 0 || startSlot < 0 || startSlot + slotCount > 128) { + return 0n; + } + const span = (1n << BigInt(slotCount)) - 1n; + return span << BigInt(startSlot); + } + + hasCollision(startSlot: number, slotCount: number, bufferPaddingSlots = 0): boolean { + const totalSlots = slotCount + bufferPaddingSlots; + const requested = SlotBitset128.createSpanMask(startSlot, totalSlots); + if (requested === 0n) return true; + return (this.mask & requested) !== 0n; + } + + merge(other: SlotBitset128): SlotBitset128 { + return new SlotBitset128(this.mask | other.rawMask); + } +} diff --git a/packages/scheduled/src/index.ts b/packages/scheduled/src/index.ts index 3630a7e34..89bdae1fc 100644 --- a/packages/scheduled/src/index.ts +++ b/packages/scheduled/src/index.ts @@ -1,310 +1,2 @@ -import { type Accessor, createSignal, getListener, getOwner, onCleanup } from "solid-js"; -import { isServer } from "solid-js/web"; - -export type ScheduleCallback = ( - callback: (...args: Args) => void, - wait?: number, -) => Scheduled; - -export interface Scheduled { - (...args: Args): void; - clear: VoidFunction; -} - -/** - * Creates a callback that is debounced and cancellable. The debounced callback is called on **trailing** edge. - * - * The timeout will be automatically cleared on root dispose. - * - * @param callback The callback to debounce - * @param wait The duration to debounce in milliseconds - * @returns The debounced function - * - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#debounce - * - * @example - * ```ts - * const fn = debounce((message: string) => console.log(message), 250); - * fn('Hello!'); - * fn.clear() // clears a timeout in progress - * ``` - */ -export const debounce: ScheduleCallback = (callback, wait) => { - if (isServer) { - return Object.assign(() => void 0, { clear: () => void 0 }); - } - let timeoutId: ReturnType | undefined; - const clear = () => clearTimeout(timeoutId); - if (getOwner()) onCleanup(clear); - const debounced: typeof callback = (...args) => { - if (timeoutId !== undefined) clear(); - timeoutId = setTimeout(() => callback(...args), wait); - }; - return Object.assign(debounced, { clear }); -}; - -/** - * Creates a callback that is throttled and cancellable. The throttled callback is called on **trailing** edge. - * - * The timeout will be automatically cleared on root dispose. - * - * @param callback The callback to throttle - * @param wait The duration to throttle - * @returns The throttled callback trigger - * - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#throttle - * - * @example - * ```ts - * const trigger = throttle((val: string) => console.log(val), 250); - * trigger('my-new-value'); - * trigger.clear() // clears a timeout in progress - * ``` - */ -export const throttle: ScheduleCallback = (callback, wait) => { - if (isServer) { - return Object.assign(() => void 0, { clear: () => void 0 }); - } - - let isThrottled = false, - timeoutId: ReturnType, - lastArgs: Parameters; - - const throttled: typeof callback = (...args) => { - lastArgs = args; - if (isThrottled) return; - isThrottled = true; - timeoutId = setTimeout(() => { - callback(...lastArgs); - isThrottled = false; - }, wait); - }; - - const clear = () => { - clearTimeout(timeoutId); - isThrottled = false; - }; - if (getOwner()) onCleanup(clear); - - return Object.assign(throttled, { clear }); -}; - -/** - * Creates a callback throttled using `window.requestIdleCallback()`. ([MDN reference](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)) - * - * The throttled callback is called on **trailing** edge. - * - * The timeout will be automatically cleared on root dispose. - * - * @param callback The callback to throttle - * @param maxWait maximum wait time in milliseconds until the callback is called - * @returns The throttled callback trigger - * - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#scheduleidle - * - * @example - * ```ts - * const trigger = scheduleIdle((val: string) => console.log(val), 250); - * trigger('my-new-value'); - * trigger.clear() // clears a timeout in progress - * ``` - */ -export const scheduleIdle: ScheduleCallback = isServer - ? () => Object.assign(() => void 0, { clear: () => void 0 }) - : // requestIdleCallback is not supported in Safari - typeof requestIdleCallback !== "undefined" - ? (callback, maxWait) => { - let isDeferred = false, - id: ReturnType, - lastArgs: Parameters; - - const deferred: typeof callback = (...args) => { - lastArgs = args; - if (isDeferred) return; - isDeferred = true; - id = requestIdleCallback( - () => { - callback(...lastArgs); - isDeferred = false; - }, - { timeout: maxWait }, - ); - }; - - const clear = () => { - cancelIdleCallback(id); - isDeferred = false; - }; - if (getOwner()) onCleanup(clear); - - return Object.assign(deferred, { clear }); - } - : // fallback to setTimeout (throttle) - callback => throttle(callback); - -/** - * Creates a scheduled and cancellable callback that will be called on **leading** edge. - * - * The timeout will be automatically cleared on root dispose. - * - * @param schedule {@link debounce} or {@link throttle} - * @param callback The callback to debounce/throttle - * @param wait timeout duration - * @returns The scheduled callback trigger - * - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#leading - * - * @example - * ```ts - * const trigger = leading(throttle, (val: string) => console.log(val), 250); - * trigger('my-new-value'); - * trigger.clear() // clears a timeout in progress - * ``` - */ -export function leading( - schedule: ScheduleCallback, - callback: (...args: Args) => void, - wait?: number, -): Scheduled { - if (isServer) { - let called = false; - const scheduled = (...args: Args) => { - if (called) return; - called = true; - callback(...args); - }; - return Object.assign(scheduled, { clear: () => void 0 }); - } - - let isScheduled = false; - const scheduled = schedule(() => (isScheduled = false), wait); - - const func: typeof callback = (...args) => { - if (!isScheduled) callback(...args); - isScheduled = true; - scheduled(); - }; - - const clear = () => { - isScheduled = false; - scheduled.clear(); - }; - if (getOwner()) onCleanup(clear); - return Object.assign(func, { clear }); -} - -/** - * Creates a scheduled and cancellable callback that will be called on the **leading** edge for the first call, and **trailing** edge for other calls. - * - * The timeout will be automatically cleared on root dispose. - * - * @param schedule {@link debounce} or {@link throttle} - * @param callback The callback to debounce/throttle - * @param wait timeout duration - * @returns The scheduled callback trigger - * - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#leadingAndTrailing - * - * @example - * ```ts - * const trigger = leadingAndTrailing(throttle, (val: string) => console.log(val), 250); - * trigger('my-new-value'); - * trigger.clear() // clears a timeout in progress - * ``` - */ -export function leadingAndTrailing( - schedule: ScheduleCallback, - callback: (...args: Args) => void, - wait?: number, -): Scheduled { - if (isServer) { - let called = false; - const scheduled = (...args: Args) => { - if (called) return; - called = true; - callback(...args); - }; - return Object.assign(scheduled, { clear: () => void 0 }); - } - - const enum State { - Ready, // 0 - default state, not scheduled - Leading, // 1 - scheduled, called leading edge, triggered only once - Trailing, // 2 - triggered more than once, will be called on trailing edge - } - - let state = State.Ready; - - const scheduled = schedule((args: Args) => { - state === State.Trailing && callback(...args); - state = State.Ready; - }, wait); - - const fn: typeof callback = (...args) => { - if (state !== State.Trailing) { - if (state === State.Ready) callback(...args); - state += 1; - } - scheduled(args); - }; - - const clear = () => { - state = State.Ready; - scheduled.clear(); - }; - if (getOwner()) onCleanup(clear); - - return Object.assign(fn, { clear }); -} - -/** - * Creates a signal used for scheduling execution of solid computations by tracking. - * - * @param schedule Schedule the invalidate function (can be {@link debounce} or {@link throttle}) - * @returns A function used to track the signal. It returns `true` if the signal is dirty *(callback should be called)* and `false` otherwise. - * - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/scheduled#createScheduled - * - * @example - * ```ts - * const debounced = createScheduled(fn => debounce(fn, 250)); - * - * createEffect(() => { - * // track source signal - * const value = count(); - * // track the debounced signal and check if it's dirty - * if (debounced()) { - * console.log('count', value); - * } - * }); - * ``` - */ - -// Thanks to Fabio Spampinato (https://github.com/fabiospampinato) for the idea for the primitive - -export function createScheduled( - schedule: (callback: VoidFunction) => VoidFunction, -): Accessor { - let listeners = 0; - let isDirty = false; - const [track, dirty] = createSignal(void 0, { equals: false }); - const call = schedule(() => { - isDirty = true; - dirty(); - }); - return (): boolean => { - if (!isDirty) call(), track(); - - if (isDirty) { - isDirty = !!listeners; - return true; - } - - if (getListener()) { - listeners++; - onCleanup(() => listeners--); - } - - return false; - }; -} +export * from "./index.js"; +export * from "./bitset.js"; diff --git a/packages/scheduled/test/bitset.test.ts b/packages/scheduled/test/bitset.test.ts new file mode 100644 index 000000000..f7c07b605 --- /dev/null +++ b/packages/scheduled/test/bitset.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { + computePowerOfTwoMask, + SlotBitset128, +} from "../src/bitset"; + +describe("Power-of-Two RingBuffer & 128-bit Slot Bitset", () => { + it("calculates exact power-of-two bitmasks", () => { + expect(computePowerOfTwoMask(100)).toEqual({ capacity: 128, mask: 127 }); + expect(computePowerOfTwoMask(1024)).toEqual({ capacity: 1024, mask: 1023 }); + }); + + it("performs branchless 128-bit slot collision detection", () => { + const schedule = new SlotBitset128(); + + schedule.occupySlot(10); + expect(schedule.isSlotOccupied(10)).toBe(true); + expect(schedule.isSlotOccupied(11)).toBe(false); + + expect(schedule.hasCollision(8, 4)).toBe(true); + expect(schedule.hasCollision(11, 4)).toBe(false); + expect(schedule.hasCollision(8, 2, 1)).toBe(true); + }); +});