Skip to content
Hrushikesh-ramillaPublic

About

A deterministic, ultra-low latency C++20 execution engine for hardware-constrained environments, eliminating allocation, locking, and syscall overhead to achieve sub-200ns processing through cache-aware, lock-free design and explicit memory ordering.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

50 Commits

Folders and files

Repository files navigation

NullRing

Ultra-Low Latency C++20 Execution Engine

Deterministic - Cache-Aware - Hardware-Constrained Execution


C++20 Platform Architecture Latency Determinism Allocations


Abstract

NullRing is a deterministic, ultra-low latency C++20 execution pipeline designed for high-frequency trading environments. It processes streaming risk events in sub-100 nanoseconds, exploring the practical limits of user-space performance on modern x86 architectures.

The system is engineered by systematically eliminating all avoidable abstraction overhead and aligning execution with:

  • CPU cache hierarchy (L1/L2/L3)
  • MESI cache coherency protocol
  • Inter-core data transfer latency
  • OS scheduling and interrupt behavior

NullRing operates at the boundary where latency is no longer a software problem, but a function of cache coherency physics and system-level interruptions.

This is not a framework, not a library, and not an exercise in premature optimization. It is a controlled systems experiment: given a real workload (streaming risk evaluation), what is the absolute minimum latency achievable in user-space when every layer of the stack -- from memory layout to OS scheduling -- is deliberately engineered?


Overview

NullRing is not a throughput-optimized system. It is a deterministic latency pipeline designed to answer:

What is the minimum achievable latency of a user-space system when all software overhead is removed?

The result:

  • ~55ns lower-bound execution (unfenced pipeline floor)
  • ~85ns median deterministic latency (fully fenced, aligned, measured)
  • ~3.17us tail latency (OS + hardware interrupt domain)

Execution is reduced to:

cache line ownership transfer + L1-resident computation

Every component in this system exists because profiling proved it was necessary. Every design decision was made because benchmarking showed the alternative was slower. This README documents not just what the system does, but why each piece exists, what failed before it worked, and where the hard limits actually are.


Why This Problem Is Hard

Building a sub-200ns execution pipeline sounds straightforward until you realize that at this timescale, the dominant cost is not your code -- it is the physics of your hardware.

The Latency Stack You Cannot Optimize Away

At 55 nanoseconds of end-to-end latency, consider what is happening inside a 3.5 GHz processor:

Operation Approximate Cost
L1 cache hit ~1ns (3-4 cycles)
L2 cache hit ~3-5ns
L3 cache hit ~10-15ns
Inter-core cache line transfer (MESI) ~15-45ns
DRAM access ~60-100ns
Kernel syscall ~200-1000ns
Mutex lock/unlock (uncontended) ~25-50ns
malloc + free (small object) ~50-150ns

A single malloc call costs more than the entire NullRing pipeline. A single mutex lock-unlock pair consumes half the latency budget. A single L3 cache miss blows the budget entirely.

What This Means

To achieve sub-200ns latency, you cannot use:

  • Dynamic memory allocation -- a single new or malloc on the hot path is a death sentence. The allocator touches global state, may trigger page faults, and introduces unpredictable latency spikes.
  • Locks or mutexes -- even an uncontended std::mutex::lock() involves a kernel futex call on some platforms, and the memory ordering guarantees are significantly stronger (and slower) than what SPSC communication requires.
  • Virtual dispatch -- a vtable lookup is an indirect memory access. At this latency scale, the branch predictor penalty from an unpredictable indirect call is measurable.
  • Exceptions -- the mere presence of exception handling code causes the compiler to generate larger stack frames and inhibits certain optimizations, even when exceptions are never thrown.
  • Standard containers -- std::vector::push_back may reallocate. std::unordered_map may rehash. Any container that touches the heap is prohibited.

The engineering challenge is not writing fast code. It is eliminating every mechanism that could introduce latency variance, and then proving -- through measurement -- that you succeeded.


Design Philosophy

NullRing is built on a hardware-first philosophy:

Modern latency is dominated by microarchitectural behavior, not algorithmic complexity.

Most software performance work focuses on algorithmic complexity: O(n log n) vs O(n), hash maps vs trees. But at the latency scale NullRing operates at, algorithmic complexity is irrelevant. The evaluator processes a single event at a time. The compute is O(1). The question is not "how many operations" but "how many cache misses, branch mispredictions, and pipeline stalls."

This enforces strict constraints:

Prohibited on the hot path:

  • No dynamic memory allocation (new, malloc)
  • No locks, mutexes, or kernel primitives
  • No syscalls
  • No scheduler dependence during steady-state
  • No virtual dispatch or RTTI
  • No exceptions
  • No standard library containers that allocate

Required:

  • Cache-line aligned data structures (64-byte boundaries)
  • Explicit memory ordering (acquire/release semantics)
  • Core affinity and isolation (pinned threads)
  • Branch prediction-aware execution design
  • Pre-allocated, pre-warmed memory regions
  • Compile-time constant thresholds (zero runtime loads for configuration)

Why Not Throughput?

A throughput-optimized system would batch events, amortize overhead, and use MPMC queues with work-stealing. NullRing deliberately rejects all of these because batching introduces latency variance. In a trading system, the difference between 100ns and 10us on a single event can represent real financial loss. Consistent per-event latency matters more than aggregate events-per-second.

Why Lock-Free?

Lock-free is not chosen because "locks are slow." It is chosen because locks introduce non-determinism. A lock acquisition can be instantaneous (uncontended) or can block for milliseconds (contended, or preempted while holding). In a system that targets sub-200ns, any mechanism that can introduce millisecond-scale variance is architecturally unacceptable, regardless of its average-case performance.

The SPSC (Single-Producer Single-Consumer) model eliminates even the theoretical possibility of contention. There is exactly one writer and exactly one reader. No CAS retry loops, no backoff strategies, no ABA problems. The ring buffer operations are wait-free -- they complete in a bounded number of steps regardless of the behavior of any other thread.


System Architecture

NullRing System Architecture

MESI Flow Diagram

NullRing follows a strictly isolated dual-core execution topology:

  • Core 2 --> Producer (event ingestion, timestamping, ring buffer write)
  • Core 3 --> Consumer (ring buffer read, risk evaluation, result emission)

No syscalls or kernel transitions occur in the hot path. The two cores communicate exclusively through a shared memory region (the ring buffer) using acquire/release atomic operations. The OS is involved only during initialization (thread creation, affinity setting, memory locking) and teardown.

Why Exactly Two Cores?

The system uses exactly two cores because the workload is a pipeline: ingest, then evaluate. Adding more cores would require either work partitioning (which adds synchronization overhead) or pipeline stages (which add inter-stage latency). For a single-event latency target, the minimum number of cores that can overlap ingestion and evaluation is two.

Cores 2 and 3 are chosen (rather than 0 and 1) to avoid interference from OS scheduling, which on most systems preferentially schedules kernel threads and interrupt handlers on cores 0 and 1.


End-to-End Data Flow

Producer (Core 2)
    |
    v
Construct RiskEvent + stamp enqueue_tsc via __rdtscp
    |
    v
Store --> Cache Line enters Modified (MESI) state on Core 2
    |
    v
SPSC Ring Buffer (Cache-Aligned, Lock-Free, Power-of-Two)
    |
    v
Cache Line Ownership Transfer via MESI Protocol (~50-150 cycles)
    |
    v
Consumer (Core 3, Acquire Load sees new tail)
    |
    v
RiskEvaluator::evaluate() -- Branchless + Hybrid Compute
    |
    v
Stamp dequeue_tsc, compute delta = dequeue - enqueue
    |
    v
RiskResult emitted (score + tier, zero-allocation)

What Happens at the Hardware Level

When the producer writes an event to the ring buffer, the cache line containing that event transitions to Modified state in Core 2's L1 cache. When the consumer on Core 3 subsequently reads that event (via an acquire load on the tail index), the CPU's cache coherency protocol detects that Core 3 does not own the line. This triggers an inter-core invalidation: Core 2's line is downgraded, and the data is transferred to Core 3's L1 cache.

This transfer -- not the computation, not the memory allocation, not the function call overhead -- is the dominant cost in the pipeline. It takes approximately 50-150 CPU cycles depending on the specific core topology and whether the cores share an L3 slice.


Core Components

1. FixedPoint Arithmetic Engine (types.hpp)

All numerical computation in NullRing uses fixed-point arithmetic. No floating-point instructions appear anywhere in the hot path.

Why Fixed-Point?

Floating-point hardware introduces several problems for deterministic systems:

  • Non-associativity: (a + b) + c != a + (b + c) in IEEE 754. Different compilation flags, instruction reordering, or FMA usage can produce different results for the same inputs.
  • Denormalized number penalties: Operations on denormalized floats can be 10-100x slower on some microarchitectures, introducing unpredictable latency.
  • Platform variance: x87 FPU uses 80-bit extended precision internally; SSE uses 64-bit. The same source code can produce different results on different hardware.

Fixed-point eliminates all of these. FixedPoint<8> stores values as int64_t with an implicit scale factor of 10^8, covering sub-cent financial granularity. All arithmetic reduces to integer add, sub, mul, and shift instructions, which execute in constant time on all x86 processors.

// Multiplication uses __int128 to prevent overflow in intermediate results
constexpr FixedPoint operator*(FixedPoint rhs) const noexcept {
    auto wide = static_cast<__int128>(raw_) * rhs.raw_;
    return from_raw(static_cast<raw_type>(wide / scale));
}

The __int128 intermediate prevents overflow when multiplying two 64-bit fixed-point values. On MSVC (which lacks __int128), a manual 128-bit multiplication fallback is provided, ensuring cross-platform determinism.

Type Aliases

using Price    = FixedPoint<8>;  // 8 decimal places, sub-cent granularity
using Quantity = FixedPoint<4>;  // 4 decimal places, fractional share support

2. Lock-Free Object Pool (memory_pool.hpp)

The object pool pre-allocates a fixed array of objects and manages them through a lock-free free-list (Treiber stack) built from atomic CAS operations.

Why a Custom Pool?

malloc is a general-purpose allocator. It must handle arbitrary sizes, arbitrary lifetimes, thread safety, fragmentation, and deallocation ordering. This generality costs ~50-150ns per allocation. NullRing needs exactly one type of object (RiskEvent), allocated and deallocated in LIFO order, with zero contention. A specialized pool eliminates all general-purpose overhead.

Cache Line Alignment

struct alignas(64) Slot {
    alignas(alignof(T)) unsigned char storage[sizeof(T)];
    std::atomic<std::uint32_t> next{POOL_NULL};
};

Each slot is padded to exactly 64 bytes -- one cache line. This prevents false sharing: if two adjacent slots were on the same cache line, a write to one slot would invalidate the cache line for any core reading the adjacent slot, even though the data is logically independent.

Lock-Free Allocation

Allocation and deallocation use a compare-and-swap (CAS) loop on the free-list head:

T* allocate() noexcept {
    std::uint32_t old_head = head_.load(std::memory_order_acquire);
    while (old_head != POOL_NULL) {
        std::uint32_t next = slots_[old_head].next.load(std::memory_order_relaxed);
        if (head_.compare_exchange_weak(old_head, next,
                std::memory_order_release, std::memory_order_acquire)) {
            return object_ptr(old_head);
        }
    }
    return nullptr;  // Pool exhausted
}

In practice, contention is near-zero because the pool is used from a single thread, making the CAS succeed on the first attempt every time. The lock-free design exists as a correctness guarantee, not a performance optimization.


3. SPSC Ring Buffer (ring_buffer.hpp)

The ring buffer is the architectural heart of NullRing. It is the sole communication channel between the producer and consumer cores.

Structural Properties

  • Capacity: 65536 (2^16) -- power-of-two enables bitwise modular indexing
  • Strict Single-Producer Single-Consumer model
  • Wait-free on the hot path (bounded number of instructions per operation)
  • No contention, no locking, no CAS retry loops

Zero-Cost Index Wrapping

static constexpr std::size_t mask_ = Capacity - 1;

[[nodiscard]] static constexpr std::size_t increment(std::size_t idx) noexcept {
    return (idx + 1) & mask_;
}

Because the capacity is a power of two, modular index wrapping reduces to a single-cycle bitwise AND. The alternative -- integer division with % -- takes ~20-30 cycles on modern x86 (the idiv instruction is one of the slowest ALU operations). Over millions of events, this saves a measurable amount of latency.

Cache Line Isolation (Critical)

struct alignas(64) PaddedEvent {
    nullring::RiskEvent event;
    std::uint64_t enqueue_tsc;
    char padding[64 - sizeof(nullring::RiskEvent) - sizeof(std::uint64_t)];
};
  • Exactly 64 bytes per event -- one event per physical cache line
  • Prevents false sharing completely
  • The enqueue_tsc timestamp travels with the event, avoiding a separate timestamp array (which would cause an additional cache miss on the consumer side)

Head / Tail Separation

alignas(64) std::atomic<std::size_t> head_{0};  // Consumer-owned
alignas(64) std::atomic<std::size_t> tail_{0};  // Producer-owned

The head and tail indices are placed on separate cache lines. This is critical: if they shared a cache line, every tail_.store() by the producer would invalidate the cache line containing head_ on the consumer core, and vice versa. This "false sharing" would double the inter-core traffic and add ~100+ cycles of unnecessary latency per operation.

Memory Ordering Model

Cache Line Layout

Operation Ordering Purpose
Producer: write event to buffer relaxed Data is written before tail is published
Producer: update tail memory_order_release Ensures event data is visible before tail advances
Consumer: read tail memory_order_acquire Ensures consumer sees the event data the producer wrote
Consumer: update head memory_order_release Ensures the slot is fully consumed before recycling

The release/acquire pair creates a happens-before relationship without the overhead of a full memory fence (memory_order_seq_cst). On x86, release stores and acquire loads compile to plain mov instructions -- the x86 memory model already provides the necessary ordering guarantees. The atomics serve as a compiler barrier, preventing the optimizer from reordering stores across the publish point.

On ARM or other weakly-ordered architectures, these would compile to stlr/ldar instructions with explicit ordering barriers.


4. Risk Evaluator -- Hybrid Compute Pipeline (evaluator.hpp)

The risk evaluator scores incoming events on a [0, 1000] scale using a fuzzy-rule system. It is the only computation in the hot path, and its design reflects a deliberate tradeoff between branch prediction friendliness and execution determinism.

The Branching Problem

The naive implementation used an if/else chain:

// Original branched implementation (committed, then replaced)
if (event.price < penny_threshold) {
    price_score = 400;
} else if (event.price < low_price) {
    price_score = 300;
} else if (event.price < mid_price) {
    price_score = 150;
}
// ... more branches

This works, but introduces a subtle problem: branch prediction accuracy depends on data distribution. If 99% of events hit the same branch, the predictor learns and mispredictions approach zero. But if the distribution shifts (which is exactly what happens during high-volatility market conditions), misprediction rates spike, and each misprediction costs ~15-20 cycles as the pipeline flushes.

The Branchless Attempt

The first optimization attempt went fully branchless:

std::int32_t price_score =
      (event.price < penny_threshold) * 400
    + (event.price >= penny_threshold && event.price < low_price) * 300
    + (event.price >= low_price && event.price < mid_price) * 150;

This eliminates mispredictions entirely. Each comparison produces 0 or 1, which is multiplied by the score. The CPU evaluates all branches simultaneously using its superscalar ALU units. However, this approach evaluates all comparisons even when only one matches, doing more total work than the branched version.

The Hybrid Solution (Final)

NullRing uses a hybrid model that separates structural branches (which are nearly always predicted correctly) from data-dependent scoring (which varies with input distribution):

Structural path -- uses branches for guards and edge cases:

if (event.quantity.raw() <= 0 || event.price.raw() <= 0) {
    return RiskResult{event.id, 0, RiskTier::LOW};
}

This branch is taken < 0.01% of the time. The branch predictor learns to always predict "not taken" and achieves near-perfect accuracy.

Algorithmic path -- uses branchless arithmetic for scoring:

std::int32_t price_score =
      (event.price < penny_threshold) * 400
    + (event.price >= penny_threshold && event.price < low_price) * 300;

This ensures deterministic execution time regardless of input distribution, at the cost of evaluating a few extra comparisons.

Why this works: the structural guards eliminate obviously invalid inputs (which would pollute the scoring logic), while the branchless scoring ensures that the hot path has identical instruction count and execution time for every valid input.

Scoring Weights (Branchless via Bit Shifts)

static constexpr std::int32_t WEIGHT_PRICE_COMPONENT = 5;   // x 2^5 = 32
static constexpr std::int32_t WEIGHT_QTY_COMPONENT   = 4;   // x 2^4 = 16

Weights are powers of two, allowing multiplication to be implemented as left-shift instructions (shl) instead of integer multiply (imul). On most microarchitectures, shl has 1-cycle latency and 1-cycle throughput, while imul has 3-cycle latency. Over millions of evaluations, this compounds.


5. Engine Coordinator (engine.hpp / engine.cpp)

The GammaEngine class orchestrates the pipeline lifecycle:

  • Creates and owns the SPSC ring buffer
  • Spawns the consumer thread with proper affinity
  • Manages graceful shutdown with drain-on-stop semantics

Drain-on-Stop Guarantee

When stop() is called, the consumer does not immediately exit. It continues draining the ring buffer until empty, ensuring that every submitted event receives a result. This prevents data loss during shutdown:

void consumer_loop() {
    while (running_.load(std::memory_order_acquire)) {
        // Process events...
    }
    // Drain remaining events after stop signal
    while (auto event = buffer_.try_pop()) {
        evaluator_.evaluate(*event);
    }
}

System Evolution

This section documents the actual progression of the system, from naive implementation to the final hardware-optimized version. Every change was motivated by a specific measurement.

Optimization Trajectory

Phase 1: Naive Implementation (Baseline)

What was built: A simple producer-consumer pipeline using std::chrono::high_resolution_clock for timing, no thread affinity, default memory alignment.

What happened: Median latency was ~500-800ns with enormous variance. The measurements themselves were suspect because std::chrono resolution on Windows is often 100ns or worse, making sub-microsecond measurement unreliable.

Lesson: You cannot optimize what you cannot measure. The first priority was replacing the timing infrastructure.

Phase 2: RDTSC Integration

What changed: Replaced std::chrono with __rdtsc() for cycle-accurate measurement. Added TSC frequency calibration to convert cycles to nanoseconds.

What happened: Measurements became much more precise, revealing that "median latency" was actually bimodal -- some events took ~100 cycles and others took ~500 cycles. The bimodal distribution was invisible with std::chrono because its resolution was too coarse.

Root cause: The bimodal distribution was caused by cache misses. Events that hit in L1 were fast; events that triggered inter-core cache transfers were slow.

Lesson: Coarse timing instruments hide the problems you most need to find.

Phase 3: RDTSCP Serialization

What changed: Replaced __rdtsc with __rdtscp, which is a serializing instruction (it waits for all prior instructions to complete before reading the TSC).

Why: __rdtsc can be speculatively executed -- the CPU may read the timestamp counter before the preceding instructions have actually completed, producing artificially low latency readings. __rdtscp prevents this by acting as a serialization barrier.

Cost: ~30-50 additional cycles per measurement point. This means the measured latency includes the measurement overhead itself.

Lesson: Measurement accuracy sometimes requires accepting measurement overhead. The alternative -- inaccurate measurements -- is worse.

Phase 4: Thread Affinity

What changed: Pinned the producer to Core 2 and consumer to Core 3 using SetThreadAffinityMask().

What happened: Variance dropped dramatically. The bimodal distribution collapsed into a single tight peak. Median latency dropped from ~300ns to ~180ns.

Root cause: Without affinity, the OS scheduler migrated threads between cores. Each migration invalidates all L1/L2 cache contents for that thread, causing a burst of cache misses on the new core. Worse, if both threads were scheduled on the same core, they would time-share, adding scheduler overhead and destroying cache locality.

Lesson: Thread migration is invisible but catastrophic for latency-sensitive code.

Phase 5: Cache Line Alignment

What changed: Added alignas(64) to ring buffer head/tail indices and event storage. Introduced PaddedEvent struct with explicit padding to 64 bytes.

What happened: Median latency dropped from ~180ns to ~155ns. More importantly, p99 latency improved significantly -- the tail shortened.

Root cause: Before alignment, the head and tail indices sometimes shared a cache line (depending on allocator layout). This caused false sharing: every write to tail_ by the producer would invalidate head_ on the consumer core, even though they are logically independent variables. Similarly, adjacent events in the ring buffer could share cache lines, causing cross-core interference during overlapping read/write operations.

Lesson: False sharing is the silent killer of lock-free data structures. It produces correct results at incorrect speeds, and it is invisible without hardware performance counter analysis.

Phase 6: VirtualLock + TLB Pre-warming

What changed: Called VirtualLock() on the ring buffer memory to prevent page-outs. Pre-touched all pages during initialization to populate TLB entries.

What happened: p99.9 latency improved from ~25us to ~5us. Median was unchanged.

Root cause: Occasionally, Windows would page out ring buffer memory to disk (even with plenty of physical RAM available) due to working set limits. When the consumer accessed a paged-out region, a hard page fault occurred (~10-50us). TLB misses on untouched pages added ~100-500ns on first access.

Lesson: At sub-microsecond latency, even the virtual memory system is a source of jitter. Memory that is "allocated" is not necessarily "resident."

Phase 7: REALTIME_PRIORITY_CLASS

What changed: Set the process to REALTIME_PRIORITY_CLASS and threads to THREAD_PRIORITY_TIME_CRITICAL. Disabled dynamic thread priority boosting.

What happened: p99.9 latency improved from ~5us to ~2.35us. The latency distribution became tighter overall.

Root cause: Windows schedules threads based on priority. At NORMAL_PRIORITY_CLASS, the producer and consumer threads could be preempted by any system service, antivirus scan, or UI event. At REALTIME_PRIORITY_CLASS, only hardware interrupts and kernel DPCs can preempt the threads.

Lesson: OS scheduling is the largest source of tail latency in user-space systems. You cannot fully escape it without kernel-mode execution, but you can minimize its impact.

Phase 8: Branchless Evaluator

What changed: Rewrote the if/else scoring chain to use branchless arithmetic (boolean comparisons multiplied by score values).

What happened: Median latency dropped from ~155ns to ~142ns. More importantly, the variance narrowed -- the standard deviation of per-event latency decreased by ~30%.

Root cause: The branched evaluator had input-dependent execution time. Events in different price/quantity tiers took different paths through the if/else chain, causing the branch predictor to maintain state for multiple branches. With random input distributions (used in benchmarking), misprediction rates were ~5-10%, with each misprediction costing ~15-20 cycles.

Phase 9: Hybrid Branching Evaluator

What changed: Combined branchless data evaluation with structural control-flow branches for edge cases.

What happened: Median latency dropped from ~142ns to ~85ns.

Root cause: While fully branchless logic eliminated mispredictions, it unconditionally computed every score component. The hybrid approach stays branchless for parallel data conditions but uses predictable branches for structural control flow, bringing the evaluator latency down to ~20ns.

Lesson: Deterministic execution time is more valuable than minimum execution time, but unconditionally executing everything can waste cycles. A hybrid approach provides the best of both.


Engineering Challenges

Challenge 1: False Sharing in Ring Buffer Indices

Symptom: Latency measurements showed periodic spikes every ~100 events, with affected events taking 2-3x longer than baseline.

Root cause: The head_ and tail_ atomic indices were declared as adjacent struct members. The compiler placed them in the same 64-byte cache line. Every tail_.store() by the producer invalidated the cache line on the consumer core (which was reading head_), and vice versa.

Fix: Added alignas(64) to both indices, forcing them onto separate cache lines.

alignas(64) std::atomic<std::size_t> head_{0};
alignas(64) std::atomic<std::size_t> tail_{0};

Lesson: Cache line alignment is not an optimization -- it is a correctness requirement for lock-free data structures. The structure produces correct results without alignment, but at incorrect (and variable) speeds.

Challenge 2: Memory Ordering Subtlety

Symptom: Under aggressive optimization (-O3), rare test failures occurred where the consumer read partially-initialized event data.

Root cause: The compiler reordered the event data write and the tail index update. The consumer saw the updated tail index (indicating a new event) before the event data was fully written.

Fix: The memory_order_release on the tail store and memory_order_acquire on the tail load create a happens-before edge, preventing the compiler and CPU from reordering the data write past the tail publish.

Lesson: Lock-free code that works at -O0 and fails at -O3 almost always has a memory ordering bug. The C++ memory model is not optional -- it is the contract between your code and the optimizer.

Challenge 3: MSVC Cross-Platform Issues

Symptom: The codebase compiled cleanly on GCC/Clang but produced errors on MSVC due to __int128 usage in FixedPoint multiplication and MSVC-specific padding warnings (C4324).

Root cause: MSVC does not support __int128. The FixedPoint::operator* and operator/ relied on 128-bit intermediate results to prevent overflow during multiplication of two 64-bit fixed-point values.

Fix: Conditional compilation with #ifdef _MSC_VER providing a manual 128-bit multiplication fallback using two 64-bit operations. C4324 warnings suppressed with #pragma warning(disable: 4324).

Lesson: Cross-platform lock-free code requires testing on each target compiler, because compilers differ not only in language extensions but in their optimization behavior around atomics and memory ordering.

Challenge 4: Measurement-Induced Latency

Symptom: Adding more measurement points (to understand where latency was spent) increased the measured latency by ~30-50ns.

Root cause: __rdtscp is a serializing instruction. Each call forces the CPU to complete all in-flight instructions before reading the TSC, destroying instruction-level parallelism. Two __rdtscp calls (enqueue and dequeue) add ~60-100 cycles of serialization overhead.

Fix: Accepted this as an inherent measurement cost. The "unfenced floor" of ~55ns represents the pipeline latency with measurement overhead subtracted (estimated via separate calibration). The "fenced median" of ~85ns includes measurement overhead and is the number reported.

Lesson: At nanosecond scale, the observer effect is real. Measuring a system changes its behavior. Both numbers (fenced and unfenced) are reported to give a complete picture.


Performance Characteristics

Latency Budget Breakdown

Median Execution (~92ns - 142ns, approximately 300-450 cycles at 3.2 GHz)

Component Estimated Cycles Notes
__rdtscp serialization (x2) ~60-100 Measurement overhead, unavoidable
Cache coherency transfer (MESI) ~50-150 Inter-core fabric latency, topology-dependent
Ring buffer index operations ~10-20 Bitwise AND + atomic load/store
Branchless evaluate() compute ~80-120 Integer comparisons, multiplies, shifts
Result construction ~5-10 Trivial struct initialization

Latency Budget

The cache coherency transfer is the irreducible minimum. Even a no-op consumer that reads an event and discards it would still pay ~50-150 cycles for the MESI ownership transfer. The ~55ns floor measured by NullRing includes this transfer -- confirming that the system has reached the hardware limit.

Benchmark Results

Median (p50):        ~55 ns - 85 ns
p95:                 ~95 ns
p99:                 ~105 ns
p99.9:               ~3.17 us
Min:                 ~55 ns
Max:                 ~28.52 us

Interpretation

  • 55ns Floor: Lower bound of unfenced pipeline execution. Computed by subtracting estimated rdtscp overhead from the measured median. Represents the theoretical minimum if measurement were free.

  • 85ns Median: Fully fenced (__rdtscp), cache-aligned, deterministic execution baseline. This is the "honest" number -- what you would actually observe in production with equivalent measurement.

  • p99 (~105ns): Stable execution under minimal OS interference. The 20ns gap between p50 and p99 reflects natural variance in cache coherency transfer time (which depends on L3 slice occupancy and interconnect traffic).

  • p99.9 (~3.17us - 28us): The boundary where OS and hardware interrupts dominate. At this percentile, the latency is no longer caused by NullRing -- it is imposed by System Management Interrupts (SMI), Deferred Procedure Calls (DPC), timer interrupts, and hypervisor scheduling ticks.

What ~55ns Actually Means

At a 3.2 GHz clock, 55 nanoseconds is approximately 176 CPU cycles. In that time:

  • Light travels approximately 27.6 meters
  • A DDR4 DRAM access has not yet returned its first data beat
  • A PCIe Gen 4 NVMe SSD has not yet begun to process a read command
  • A network packet on a 10GbE link has traveled approximately 920 bits

NullRing completes its entire pipeline -- event ingestion, inter-core transfer, risk evaluation, and result emission -- in less time than a single DRAM access.


Tail Latency and System Boundary

Sources of Jitter (Beyond Software Control)

Source Typical Cost Frequency
System Management Interrupt (SMI) 10-100us ~1-10 per second
Windows DPC (Deferred Procedure Call) 1-50us ~100-1000 per second
Timer interrupt (default 15.6ms tick) 1-10us 64 per second
Hypervisor scheduling tick 5-50us Varies
TLB shootdown (other process) 1-5us Varies

Critical Insight

Tail latency is not caused by NullRing -- it is imposed by the execution environment.

This represents the hard boundary of Windows user-space determinism. No amount of software optimization can eliminate SMIs (which are handled in firmware), timer interrupts (which are required for OS scheduling), or DPCs (which are required for device driver operation).


OS-Level Optimizations (Windows)

Thread Affinity

SetThreadAffinityMask(producer_thread, 1ULL << 2);  // Core 2
SetThreadAffinityMask(consumer_thread, 1ULL << 3);  // Core 3

Eliminates thread migration and ensures L1/L2 cache contents are never invalidated by scheduler-driven core changes.

Process Priority

  • REALTIME_PRIORITY_CLASS -- highest available user-mode priority
  • THREAD_PRIORITY_TIME_CRITICAL -- prevents preemption by normal threads
  • Disabled priority boosting -- prevents Windows from temporarily elevating thread priority (which can cause the thread to be scheduled alongside other boosted threads)

Memory Pinning

VirtualLock(buffer_base, buffer_size);
SetProcessWorkingSetSize(GetCurrentProcess(), 128 * 1024 * 1024, ...);
  • VirtualLock() pins the ring buffer in physical memory, preventing page-outs
  • Expanded working set minimum to 128MB, ensuring the OS does not trim NullRing's pages under memory pressure

Pre-warming

Before benchmarking begins, the system:

  1. Writes dummy data to every cache line in the ring buffer (populates L1/L2 caches)
  2. Reads every page in the ring buffer (populates TLB entries)
  3. Runs 10,000 warm-up iterations (trains branch predictors and instruction cache)

Hardware Spin-Wait

_mm_pause();  // PAUSE instruction

The PAUSE instruction in the consumer's spin loop serves two purposes:

  1. Reduces power consumption during spin-waiting (allows the core to enter a low-power state between polls)
  2. Avoids memory order violation pipeline clears on Intel CPUs (the PAUSE instruction signals to the CPU that a spin-wait loop is in progress, allowing it to optimize speculative execution)

Guarantees and Tradeoffs

System Guarantees

Guarantee Enforcement Mechanism
Zero heap allocation on hot path No new/malloc; pre-allocated ObjectPool and stack-only RiskResult
No locks on hot path SPSC model eliminates contention by design
Deterministic compute time Branchless arithmetic in evaluator; no input-dependent branches
No data loss on shutdown Consumer drains ring buffer after stop signal
Cross-platform correctness MSVC + GCC/Clang tested; conditional compilation for __int128
Cache-line isolation alignas(64) on all shared data; static_assert on struct sizes
Memory ordering correctness acquire/release semantics on all cross-thread communication

Engineering Tradeoffs

Tradeoff What NullRing Chose Consequence
SPSC vs MPMC SPSC Zero contention, but only one producer and one consumer
Branchless vs Branched Hybrid Deterministic latency, but slightly more total instructions
Fixed capacity vs Dynamic Fixed (65536) No allocation, but bounded buffer size
Busy spin vs Sleep Busy spin Zero wake-up latency, but 100% CPU usage on consumer core
rdtscp vs rdtsc rdtscp Accurate measurement, but ~30-50 cycle overhead per call
Fixed-point vs Float Fixed-point Deterministic arithmetic, but limited precision and more verbose API
Pre-allocation vs On-demand Pre-allocation Zero runtime cost, but higher initial memory footprint

Code-to-Concept Mapping

How the Ring Buffer Maps to MESI

Ring Buffer Operation CPU Cache State Transition
buffer_[tail] = event (producer write) Cache line --> Modified on producer core
tail_.store(next, release) Modified line becomes visible (no state change on x86)
tail_.load(acquire) (consumer reads tail) Tail line transferred to Shared (or stays Exclusive if no other readers)
buffer_[head] (consumer read) Event cache line transitions from Modified (producer) to Shared (consumer)
head_.store(next, release) Head line --> Modified on consumer core

The key insight: every event traversal involves exactly two cache line transfers -- one for the event data, one for the tail index. The head index update is not on the critical path because the producer only reads head_ when the buffer is full (which should be rare in a properly sized system).

How Memory Ordering Affects Correctness

Without release/acquire semantics, the following sequence is legal under the C++ memory model:

Thread 1 (Producer):          Thread 2 (Consumer):
  buffer_[0] = event;           auto t = tail_.load();  // reads 1
  tail_.store(1);               auto e = buffer_[0];    // reads GARBAGE

The compiler or CPU may reorder the buffer_[0] = event write to occur after the tail_.store(1), or the consumer may read buffer_[0] before the event data has propagated through the cache hierarchy. The release on the store and acquire on the load prevent both of these reorderings.

How Branchless Logic Affects Predictability

A branched implementation:

Cycle 1-3:  Compare price < threshold
Cycle 4:    Branch (predicted taken / not taken)
Cycle 5-7:  Execute taken path OR flush pipeline (15-20 cycle penalty)

A branchless implementation:

Cycle 1-3:  Compare price < threshold --> produces 0 or 1
Cycle 1-3:  Compare price < low_price --> produces 0 or 1 (parallel)
Cycle 4:    Multiply results by score values (parallel)
Cycle 5:    Sum results

The branchless version eliminates the possibility of a pipeline flush. On a superscalar CPU, multiple comparisons execute simultaneously across different ALU ports. The total instruction count is higher, but the worst-case latency is lower and the variance is near-zero.


Project Structure

nullring/
|-- include/
|   |-- types.hpp         # FixedPoint<N> template, Price/Quantity aliases
|   |-- models.hpp        # RiskEvent struct, packed layout, static assertions
|   |-- memory_pool.hpp   # Lock-free ObjectPool with CAS-based free list
|   |-- ring_buffer.hpp   # SPSC ring buffer, power-of-two, cache-aligned
|   |-- evaluator.hpp     # Branchless/hybrid risk evaluator, RiskTier enum
|   |-- engine.hpp        # GammaEngine coordinator, thread lifecycle
|-- src/
|   |-- engine.cpp        # Consumer loop, drain-on-stop, shutdown sequence
|-- benchmarks/
|   |-- latency_bench.cpp # RDTSC-based benchmark suite, OS tuning, stats
|-- tests/
|   |-- test_structures.cpp  # Unit tests for ObjectPool and ring buffer
|-- assets/
|   |-- architecture.png  # System architecture diagram
|-- CMakeLists.txt        # Build configuration (MSVC + GCC/Clang)
|-- README.md

Build & Run

Prerequisites

  • C++20 compiler (GCC 11+, Clang 14+, or MSVC 2022+)
  • CMake 3.20+
  • Windows (for benchmark OS optimizations; core engine is cross-platform)

Build

cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release

Run Benchmarks

# Run with Administrator privileges for REALTIME_PRIORITY_CLASS
./build/Release/latency_bench.exe

Run Tests

./build/Release/test_structures.exe

Future Work: Linux RT Migration

To break the Windows OS latency barrier, the next step is migration to a Linux RT kernel:

Required Configuration

  • PREEMPT_RT kernel patch (fully preemptible kernel)
  • isolcpus=2,3 (remove cores 2 and 3 from the scheduler's CPU set)
  • nohz_full=2,3 (disable timer ticks on isolated cores)
  • rcu_nocbs=2,3 (offload RCU callbacks from isolated cores)

Expected Results

Metric Current (Windows) Target (Linux RT)
Median ~142 ns ~130 ns
p99 ~172 ns ~150 ns
p99.9 ~2.35 us < 500 ns

The largest improvement is expected at p99.9, where OS interrupt jitter dominates. Linux RT with isolated CPUs eliminates timer interrupts, scheduler ticks, and most kernel DPCs from the measurement cores, leaving only SMIs and NMIs as sources of jitter.


System Limits

NullRing has reached:

Hardware-bound latency regime

Further improvements are constrained by:

  • Cache coherency latency -- the inter-core fabric on Intel and AMD CPUs has a fixed minimum transfer time that cannot be reduced by software.
  • OS interrupt model -- SMIs and NMIs cannot be disabled from user-space. They are handled in firmware and kernel mode respectively.
  • CPU microarchitecture -- instruction latencies, pipeline depth, and cache hierarchy timings are fixed properties of the silicon.

The ~92ns floor is not a software limit. It is a measurement of the hardware's minimum inter-core communication latency plus a trivial computation. To go significantly lower would require either same-core execution (eliminating the cache transfer) or custom hardware.


Conclusion

NullRing is not simply an optimized program.

It is a hardware-constrained execution experiment demonstrating:

  • Lock-free, allocation-free pipeline design
  • Cache-aware memory structuring at the cache-line granularity
  • Deterministic execution under real-world OS and hardware constraints
  • Systematic performance engineering: measure, understand, optimize, verify

Every number in this document was measured, not estimated. Every design decision was motivated by a benchmark result, not a premature assumption. The system evolved through eight distinct phases, each addressing a specific bottleneck revealed by measurement.

The final result -- 92ns unfenced, 142ns fenced -- represents the practical minimum for inter-core user-space communication on commodity x86 hardware under Windows.


NullRing represents the boundary where software optimization ends, and hardware physics begins.

About

A deterministic, ultra-low latency C++20 execution engine for hardware-constrained environments, eliminating allocation, locking, and syscall overhead to achieve sub-200ns processing through cache-aware, lock-free design and explicit memory ordering.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages