🟥 Stackful cooperative task runtime
SaneCppFibers.h is an experimental stackful task runtime: code can suspend from an ordinary nested call stack without blocking the OS thread that runs it. Tasks, stacks, workers, and bounded queues remain explicit program-owned resources.
For CPU work that never suspends, the library also includes an early FiberJob prototype. Jobs are stackless, run-to-completion records with a separately bounded scheduler, so they do not pay for fiber stacks or weaken the stackful scheduling contract.
- Warning
- The library is a draft. The implementation has broad test coverage, including multi-worker execution, but its API and operational experience are not yet mature enough to treat it as a stable general-purpose job system.
Dependencies
- Dependencies: (none)
- All dependencies: (none)
Where Fibers Fits
Fibers is a CPU/tasking runtime for code that wants synchronous-looking control flow without blocking an OS thread. A fiber owns a stack, can call normal C++ functions, and can cooperatively suspend with FiberScheduler::yield() or by waiting on fiber primitives. Later it can resume on the same worker or on another worker.
It is aimed at bounded micro-tasking workloads: many short jobs over time, reusable task and stack slots, optional work stealing between worker threads, and memory budgets selected in advance by the caller. It is not an I/O library, preemptive thread scheduler, or transparent replacement for std::thread.
Use Fibers when you want:
- stackful tasks that can suspend from ordinary call stacks;
- caller-owned task, stack, worker, and queue storage;
- cooperative synchronization primitives such as events, semaphores, mutexes, counters, and task groups;
- a runtime that can run on one thread or on a caller-provided worker pool when parallelism is useful;
- no dependency on Async, Await, or Threading.
The Scheduling Model
FiberScheduler owns the logical scheduling state, but not the storage of the things it schedules. A task is made from:
- a caller-owned
FiberTask; - a caller-owned
FiberStack, or a slot acquired throughFiberTaskPool; - a
FiberTask::Procedurereturning plainResult; - optional cancellation, counter, and user-data inputs through
FiberTaskSpawnOptions.
The scheduler runs a ready task until it completes, explicitly yields, or waits on a fiber primitive. This is cooperative: a task that neither returns nor suspends monopolizes its worker. When a task becomes runnable again, intrusive links in FiberTask put it back on a ready queue without allocating.
runOnce() and the other scheduler-driving calls are useful for a single-threaded owner. FiberWorkerPool instead owns OS threads while it is running and lets workers steal ready tasks. Parallel workers do not change the cooperative rule inside each task, and they mean resumed code must be safe to run on a different OS thread.
A Representative CPU Workload
FiberTaskPool is the ergonomic way to run many bounded tasks without manually pairing each task with a stack. The pool does not grow: if all slots are active, producers can wait for capacity and try again.
struct State
{
int partials[3] = {};
} state;
constexpr size_t NumTasks = 3;
FiberScheduler scheduler;
FiberTask tasks[NumTasks];
char stackMemory[NumTasks * 64 * 1024] = {};
FiberTaskPool pool(tasks, stackMemory, sizeof(stackMemory) / NumTasks);
FiberTaskGroup group(scheduler);
for (size_t taskIndex = 0; taskIndex < NumTasks; ++taskIndex)
{
auto task = [&state, taskIndex](FiberScheduler& scheduler)
{
for (int value = 0; value < 5; ++value)
{
state.partials[taskIndex] += static_cast<int>(taskIndex + 1) * value;
SC_TRY(scheduler.yield());
}
return Result(true);
};
SC_TRY(group.spawn(pool, move(task)));
}
SC_TRY(group.waitAll());
int total = 0;
for (int partial : state.partials)
{
total += partial;
}
console.print("CPU fibers completed: partials=[{}, {}, {}], total={}\n", state.partials[0], state.partials[1],
state.partials[2], total);
return Result(true);This is still ordinary C++ control flow. The call to yield() cooperatively gives another ready fiber a chance to run, but no OS thread is blocked waiting for preemption.
Stackless Jobs
FiberJob is for small CPU callbacks that never yield or wait. The caller supplies fixed ready-queue storage and can either drive the scheduler explicitly or attach a bounded worker pool. Job failures remain on the completed job, while run() reports scheduler failures.
FiberJobScheduler jobScheduler;
FiberJob* readyStorage[32] = {};
FiberJob jobs[32];
SC_TRY(jobScheduler.create(readyStorage));
for (FiberJob& job : jobs)
{
SC_TRY(jobScheduler.spawn(
job, FiberJob::Procedure([](FiberJobContext& context)
{
SC_TRY(context.checkCancellation());
return Result(true);
})));
}
SC_TRY(jobScheduler.run());
SC_TRY(jobScheduler.close());The queue never allocates or grows. spawn() reports capacity exhaustion, and a running job may submit children only while a slot is available. FiberJobContext intentionally has no yield() or fiber synchronization API. Parallel job workers execute and steal accepted jobs without reserving per-job stacks. Recursive help-while-full fan-out remains future Draft work; use FiberTask whenever the callback must suspend or call AsyncFibers.
FiberJobPool provides O(1) acquisition from a fixed Span<FiberJob>. A completed job remains retained until the caller has inspected result() and calls release(). This makes result lifetime and backpressure explicit: failed scheduler publication immediately returns the attempted record, while unreleased completed records continue to count against pool capacity. For allocator-backed storage, create a FiberJobClass with an explicit FiberAllocator and bind the pool with pool.create(jobClass). The class owns only stable record storage; the pool contract stays the same.
FiberJobGroup submits one bounded wave through a pool, drives its scheduler with run(), and retains every job result for countErrors() or caller-provided collectErrors() storage. Call reset() after inspection to return all records to their originating pools. A group never allocates and cannot start another completed wave before reset. Internal pool retention and group membership are serialized in preparation for parallel workers, and group ownership is established before scheduler publication. Group wave construction and result inspection remain caller-driven lifecycle operations; do not mutate one group concurrently.
FiberJobWorker is a caller-owned parallel execution record. Its bounded local deque storage is created explicitly through FiberJobScheduler::createWorkerDeques() and a FiberAllocator; partial setup failure rolls back every deque allocated by that call. The worker overloads of runOne() and run() provide deterministic manual execution: jobs spawned by a running worker publish to its local deque, and another supplied worker can steal from the opposite end. Once worker-local work exists, callers must continue with a worker overload; plain run() reports that it cannot drain local deques. The bounded external queue is serialized independently, so caller-managed threads may invoke worker runOne() concurrently and execute jobs in parallel.
Callers that own contiguous stable records can publish them transactionally with FiberJobScheduler::spawn(Span<FiberJob>, Procedure). An external producer validates the entire span and bounded queue, copies the fixed-size procedure into each record under one queue lock, updates accounting once, and wakes the pool as a batch. A running job instead places the complete batch on its owner deque when it fits, with one accounting update and one deque-bottom publication, then wakes one parked peer to steal from the opposite end. Later publications recruit more peers without broadcasting at every recursive fan-out node. If the complete batch does not fit locally, the scheduler uses the bounded external transaction rather than splitting the call. Empty spans, active records, invalid pooled records, or insufficient capacity return an error without partial publication.
FiberJobWorkerPool adds library-owned OS threads without depending on Threading. Worker records and thread records remain caller-owned, while every local deque comes from the explicit FiberAllocator in the options. By default, join() drains one accepted wave. Set keepAliveWhenIdle to keep the same threads and deques parked between waves; waitIdle() then marks each wave boundary, and requestStop() is required before the final join(). Stop wakes every parked worker and makes cancellation observable through FiberJobContext. Multi-worker pools transfer claimed batches to cache-line-isolated worker ready and active counters, so local execution and completion do not contend on scheduler-wide counters. readyJobCount() and activeJobCount() are exact at stable observation points and can only conservatively overcount during a concurrent ownership transfer; neither reports a false zero. No stack is reserved for a job.
FiberJobWorkerPoolOptions options;
options.dequeAllocator = &allocator;
options.dequeCapacityPerWorker = 256;
SC_TRY(jobScheduler.spawn(job, procedure));
SC_TRY(workerPool.start(jobScheduler, workers, threads, options));
SC_TRY(workerPool.join());Long-lived runtimes can accept repeated bounded waves without recreating their OS threads:
FiberJobWorkerPoolOptions options;
options.dequeAllocator = &allocator;
options.dequeCapacityPerWorker = 256;
options.keepAliveWhenIdle = true;
SC_TRY(workerPool.start(jobScheduler, workers, threads, options));
SC_TRY(jobScheduler.spawn(firstJob, firstProcedure));
SC_TRY(workerPool.waitIdle());
SC_TRY(jobScheduler.spawn(secondJob, secondProcedure));
SC_TRY(workerPool.waitIdle());
SC_TRY(workerPool.requestStop());
SC_TRY(workerPool.join());waitIdle() observes that all work accepted before that idle point has completed. External producers must be stopped or otherwise coordinated if the caller needs a closed submission boundary; a concurrent later spawn begins a new wave. A persistent pool rejects join() until stop has been requested, avoiding an accidental indefinite wait while it is still accepting work.
Capacity Is Part of Control Flow
When producing more work than the pool can hold at once, capacity pressure is explicit. From inside a fiber, waitForSpawnCapacity() suspends cooperatively until at least one pool slot is available.
Capacity is observable through hasAvailableTask() and availableCount(). waitForSpawnCapacity() cooperatively suspends a producing fiber until a slot becomes available; an external producer must instead drive or coordinate with the scheduler. There is no unbounded overflow queue hidden behind spawn().
Adding Worker Threads
FiberWorkerPool runs one FiberScheduler on caller-provided OS thread storage. Workers can steal ready fibers from each other, and optional allocator-backed worker deques avoid placing worker queue storage on the heap.
static constexpr size_t NumWorkers = 4;
FiberScheduler scheduler;
FiberWorker workers[NumWorkers];
FiberWorkerThread threads[NumWorkers];
FiberWorkerPool workerPool;
SC_TRY(workerPool.start(scheduler, workers, threads));
SC_TRY(workerPool.join());For higher-throughput scheduling, explicit deque storage can be provided through FiberAllocator. The fixed allocator uses a caller buffer; the virtual allocator reserves a caller-selected address-space budget and commits pages on demand.
char allocatorStorage[64 * 1024] = {};
FiberAllocator allocator;
SC_TRY(allocator.createFixed(allocatorStorage));
FiberWorkerPoolOptions options;
options.dequeAllocator = &allocator;
options.dequeCapacityPerWorker = 256;
options.injectionAllocator = &allocator;
options.injectionCapacity = 256;
SC_TRY(workerPool.start(scheduler, workers, threads, options));
SC_TRY(workerPool.join());
SC_TRY(allocator.close());The worker pool owns OS threads while running, but the memory for workers, thread handles, deques, tasks, and stacks is still selected by the caller. join() waits for scheduled work and worker shutdown; lifecycle ordering is therefore part of application shutdown rather than destructor magic. If start() fails after creating allocator-backed queues, it releases every partial queue allocation and leaves the pool, scheduler, worker/thread storage, and allocator reusable.
The optional injection queue is the bounded entry point for tasks submitted from external threads and for cross-worker wakeups. A new spawn() reports an error if that queue is full. A fiber that is already active never fails merely because the queue is full: its wakeup uses the scheduler's intrusive spill path, and workers prioritize that spill so existing work continues to make progress. FiberSchedulerDiagnostics exposes the configured capacity, current and peak occupancy, in-progress publications, spill count, and injection-control contention separately from scheduler coordination; peak and spill values remain available after join(). Ordinary counter-free external spawns publish through a slot-sequenced bounded queue, so producers and workers do not serialize on queue indices. Injection control remains only around the pre-claim cancellation registry. Fixed allocator budgets should include injectionCapacity * FiberInjectionSlotStorageSize bytes in addition to worker deque storage and allocator alignment.
FiberTaskPool::waitForAvailableTask() provides one-slot backpressure. Producers that refill in batches can instead call waitForAvailableTasks(scheduler, minimumAvailable) to suspend until that many task/stack pairs are reusable, reducing wake/resuspend churn without allocating. The minimum must be between one and the pool capacity. Multiple waiters remain FIFO, so a larger threshold at the head preserves arrival order rather than allowing a later smaller request to bypass it. Cancellation removes a waiting fiber through the same cooperative waiter path.
Configured pools with peer workers transfer a bounded injection backlog into local stealable deques in larger batches than the latency-sensitive spill path. Workers claim these slot-sequenced batches without taking the scheduler-global ready lock; per-task injection control still transfers cancellation-registry ownership safely. The intrusive spill path retains scheduler coordination. injectionClaimBatchPeak exposes the observed transfer size for tuning and regression analysis.
Choosing Task and Stack Storage
For small fixed workloads, FiberTaskPool(Span<FiberTask>, Span<char>, stackSize) partitions one buffer into stack slots. Every slot reserves its entire stack in physical memory, which is simple but can become expensive at high capacities. Stack size is a hard correctness limit, not just a performance knob; overflowing it is not recoverable.
For larger systems, the draft API also has reusable allocator-backed task records and virtual-memory-backed stack slots:
FiberAllocator allocator;
FiberAllocatorVirtualOptions allocatorOptions;
allocatorOptions.reserveBytes = 8 * 1024 * 1024;
allocatorOptions.initialCommitBytes = 64 * 1024;
SC_TRY(allocator.createVirtual(allocatorOptions));
FiberTaskClass taskClass;
FiberTaskClassOptions taskOptions;
taskOptions.maxTasks = 1024;
SC_TRY(taskClass.create(allocator, taskOptions));
FiberStackClass stackClass;
FiberStackClassOptions stackOptions;
stackOptions.stackSizeInBytes = FiberStackSize::ThirtyTwoKiB;
stackOptions.maxStacks = 1024;
stackOptions.guardPage = true;
SC_TRY(stackClass.reserve(stackOptions));
FiberTaskPool pool;
SC_TRY(pool.create(taskClass, stackClass));FiberStackClass reserves fixed-size virtual slots, optionally with guard pages, and reports committed and high-water usage. FiberTaskClass bounds reusable task records. Both must outlive every slot acquired from them, and close-time validation rejects live allocations or task/stack slots. A class-backed FiberTaskPool::spawn() owns both a task and a stack or neither: failed stack acquisition or scheduler publication returns every acquired slot, clears a provided outTask, and leaves the pool reusable.
Stack Class Sizing
FiberStackSize names common requested sizes for virtual stacks: FourKiB, EightKiB, ThirtyTwoKiB, and SixtyFourKiB. These are inputs to FiberVirtualStackOptions and FiberStackClassOptions, not runtime-selected profiles. The OS rounds requested stack and guard sizes to its page size, so FiberStackClassDiagnostics is the source of truth for actual reservation and committed bytes.
Use 4 KiB or 8 KiB only for shallow, measured procedures. 32 KiB is a reasonable starting point for dense cooperative workloads, while 64 KiB remains the conservative default. Stack requirements include ordinary nested calls, C++ temporaries, and any library work below a suspension point. Measure high-water use with fillHighWaterMarks() before reducing a production stack class, and retain enough margin for platform and build-mode variation.
Waiting, Coordination, and Cancellation
Fiber primitives suspend the current fiber instead of blocking the OS thread:
FiberCounterwaits for a counted set of operations to complete.FiberEventwakes all waiters when signaled.FiberAutoResetEventwakes one waiter per signal.FiberSemaphorecontrols access to a fixed number of logical slots.FiberMutexprotects cooperative fiber critical sections and diagnoses recursive or wrong-owner use.FiberTaskGroupspawns child tasks and collects errors without dynamic allocation.
Cooperative event, auto-reset event, semaphore, and mutex waits must run inside a fiber owned by the supplied scheduler. FiberCounter is the deliberate exception: its scheduler wait can also drive ready work from the root caller. Event and semaphore signals may be published from another thread through that scheduler. Mutex ownership transfers to the chosen waiter before its wake is published, so the previous owner cannot unlock twice or let another fiber enter during the handoff window. If a selected waiter is canceled before resuming, one-shot ownership from an auto-reset event, semaphore, or mutex is transferred to another waiter or retained for the next wait rather than being lost.
A task group retains the completed task records from one wave so countErrors() and collectErrors() can report stable task identities even when the tasks came from a reusable pool. After waiting and inspecting results, call FiberTaskGroup::reset() to release those records back to fixed or class-backed pools. Reset fails while tasks are pending, and a group does not accept a new wave until the completed wave has been reset. Stack slots are released at normal root-context completion; only the smaller task records remain retained for result inspection.
For example, a semaphore can bound how many fibers enter a cooperative region:
FiberSemaphore limit(4);
SC_TRY(group.spawn(pool, FiberTask::Procedure(
[&limit](FiberScheduler& scheduler)
{
SC_TRY(limit.wait(scheduler));
Result result = doWork();
SC_TRY(limit.signal(scheduler));
return result;
})));Cancellation is cooperative. FiberCancellationTokenSource can request cancellation for a group of spawned tasks, and the scheduler wakes interruptible waits so tasks can return an error Result.
FiberCancellationTokenSource cancelSource;
FiberTaskSpawnOptions options;
options.cancellationToken = cancelSource.token();
SC_TRY(pool.spawn(scheduler, makeCancellableJob(), options));
SC_TRY(scheduler.requestCancel(cancelSource));Tasks should still return plain Result; there is no exception dependency and no hidden cancellation object allocation. Cancellation cannot interrupt arbitrary computation: task code must reach a cancellation-aware wait or explicitly check its token.
Lifetime and Thread-Local State
FiberTask execution is not pinned to the OS thread that first started the task. A task may yield, become ready again, and later resume on a different worker thread, for example after work stealing or when another thread drives the same FiberScheduler.
Do not use C++ thread_local variables or platform TLS to store logical fiber task state. Those values belong to the current OS thread, not to the fiber, so a resumed task may observe a different value than it wrote before suspension. Use explicit task state instead, such as captured state in the task procedure, caller-owned objects, or FiberTask::userData().
The task object, its stack, procedure captures, userData, synchronization primitives, and any state reached through them must remain alive and at stable addresses until the task completes. A FiberTaskGroup helps join related work but does not make borrowed state owning. Destroying a scheduler or storage class while work is active is a programming error diagnosed by the library.
Cancellation tokens borrow their FiberCancellationTokenSource; the source must outlive every token and every task spawned with one. Treat FiberCancellationTokenSource::reset() as a between-wave lifecycle operation after all prior token users have completed, not as a way to revoke cancellation from active work.
FiberScheduler serializes task publication, cancellation, and wake operations used by external producers. A fixed span-backed FiberTaskPool does not serialize its slot scan or nextTask cursor, so calls that acquire/spawn pool slots must come from one producer or be externally serialized; task execution and completion may still happen on many workers. Class acquire/release operations have their own internal serialization. Reserve/create/close/release and worker-pool start/join are quiescent lifecycle operations; FiberWorkerPool::requestStop() is the cross-thread stop signal.
FiberScheduler::shutdown() is a reusable cancellation drain, not a permanent close. It requests cancellation for the currently active tasks and drives them back to completion. Calling it again with no active work succeeds, and new tasks may be spawned afterward. A worker pool has a separate lifecycle: requestStop() wakes its workers and join() must finish before worker, thread, deque, or injection storage is reused or destroyed.
Normal ready publication logically requests one worker wake. The pool coalesces redundant operating-system signals while an earlier signal is still pending, and skips the condition-variable mutex entirely when no worker has published park intent. Every publication still atomically advances the wake generation so publication racing with parking cannot be lost. Shutdown and the transition to no active fibers remain wake-all operations. Use FiberWorkerPool::wakeDiagnostics() to compare logical wakeNotifications with the individual wakeSignals that remain after coalescing; broadcasts are included only in wakeNotifications.
Allocation Policy
Fibers follows the Sane C++ allocation rules:
- no hidden dynamic allocation in normal scheduling paths;
- tasks, stacks, workers, and thread handles are caller-owned;
- queue/deque storage is explicit through
FiberAllocatorwhen enabled; FiberAllocator::createMalloc()exists only as an explicit opt-in mode;- virtual stack storage uses explicit reservation and capacity limits;
- close-time validation catches live allocations or live task/stack slots.
This means the runtime applies backpressure instead of silently growing. Producers provide enough capacity, wait for capacity, or handle setup/allocation failure. FiberAllocator::createMalloc() is an explicit interoperability escape hatch; choosing it gives up the fixed-memory property even though allocation remains visible in configuration and statistics.
Relationship to Neighboring Libraries
Async is the low-level callback I/O library. Await is a C++20 coroutine wrapper over Async. Fibers is different: it is stackful, does not require C++20 coroutines, and can suspend through ordinary nested function calls because each fiber has an explicit stack.
I/O integration is intentionally not part of Fibers; it lives in AsyncFibers, which depends on both Fibers and Async.
Threading is the lower-level choice when work naturally maps to OS threads or must block in foreign code. Fibers trade a per-task stack and cooperative scheduling discipline for the ability to suspend through ordinary nested functions. Await trades that stack for compiler-generated coroutine frames and requires C++20.
API Reference
The complete class and method reference is in the Fibers API group. Start with FiberScheduler, FiberTaskPool, FiberTaskGroup, FiberWorkerPool, and the stackless FiberJobScheduler. Storage-heavy deployments should also read FiberTaskClass, FiberStackClass, and their diagnostics types.
Further Examples
Examples/FibersDemoshows a tiny CPU fiber workload,AsyncFiberssleeps, and worker-pool I/O.Examples/FibersBenchmarkcontains explicit benchmark-style workloads for yield/resume, sustained micro-tasking, raw stackless dispatch, and pooled stackless acquire/run/release overhead. Its--job-worker-matrix --job-rounds <COUNT>mode runs one warm-up and bounded repeated samples at unique 1/2/4/8/hardware-worker counts, reporting minimum, median, mean, and maximum throughput without allocating sample storage. Its--job-worker-sustained --job-workers <COUNT>mode keeps one job worker pool alive and reuses a fixed 8,192-record batch across one million jobs, timing transactional batch publication through each explicit idle boundary. It reports both the stackful-comparable shared-atomic payload and an independent per-record output variant that isolates scheduler throughput from application cache-line contention.Examples/FibersSkynetBenchmarkis enabled after./SC.sh package install taskflow-benchmarksand compares the stackful scheduler and stackless jobs with Taskflow's pinned upstream Skynet backend. The stackful depth limit is explicit because each liveFiberTaskowns a fixed stack; theFiberJobbackend uses distinct stable continuation records and supports the canonical million-leaf workload without per-job stacks. Its persistent worker pool is reused across warm-up and measured waves at each depth.--job-idle-spins <COUNT>isolates the bounded worker spin policy when profiling parking and wake overhead; its default matchesFiberJobWorkerPoolOptions.Tests/Libraries/Fibers/FibersTest.cppis the best source of focused examples for cancellation, primitives, task pools, worker pools, work stealing, diagnostics, virtual stacks, and allocator-backed storage.
Status
🟥 Draft
This Draft does not promise binary compatibility. The stabilization candidate changes public caller-sized layouts, including task and spawn metadata, so binary consumers must recompile against the matching headers. No compatibility shim is provided or required while the library remains Draft.
Current support includes:
- caller-provided
FiberStackmemory; - virtual stack reservation and fixed-size
FiberStackClasspools; - internal context creation and switching on macOS, Linux, and Windows for supported 64-bit architectures;
- caller-owned
FiberTaskobjects and allocator-backedFiberTaskClassstorage; - caller-owned stackless
FiberJobrecords with a fixed-capacity, single-thread-driven scheduler prototype; - fixed-storage or allocator-backed
FiberJobPoolreuse with explicit completed-result retention and release; - bounded
FiberJobGroupwaves with cancellation, aggregate errors, and explicit pooled-record reset; - caller-owned
FiberJobWorkerrecords with allocator-backed bounded deque storage and startup rollback; - no-allocation
FiberJobWorkerPoolexecution with work stealing, cooperative stop, and parked-worker wake-up; - fixed-storage and class-backed
FiberTaskPool; - single-threaded
FiberSchedulerspawn, run, yield, and no-progress detection; - worker-pool execution with work stealing and optional allocator-backed worker deques;
- scheduler, worker, stack, pool, and allocator diagnostics;
- optional tracing hooks with no allocation on the hot path;
FiberCounterwait from both fibers and the root caller;FiberTaskGroupconvenience spawning, wait-all result reporting, cancel-on-error, and pool-backed bounded fan-out;- cooperative
FiberEvent,FiberAutoResetEvent,FiberSemaphore, andFiberMutexprimitives; - cooperative task cancellation, including waking tasks suspended on counters and primitives;
- focused
SCTestcoverage for the raw context switch layer, scheduler primitives, worker pools, and storage classes.
Blog
Development notes and design changes are recorded in the project updates:
Statistics
LOC counts exclude comments. Library counts files physically under Libraries/Fibers. Single File counts SaneCppFibers.h. Standalone counts SaneCppFibersStandalone.h and intentionally includes dependency payloads.
| Metric | Header | Source | Sum |
|---|---|---|---|
| Library | 1453 | 7731 | 9184 |
| Single File | 2207 | 7966 | 10173 |
| Standalone | 2207 | 7966 | 10173 |