Zero-Allocation Hot Path

Read time 7 minutes
On this page

To support thousands of concurrent connections with sub-millisecond latency, Nalix implements a “Zero-Allocation Hot Path.” During peak traffic, the core networking loop executes without triggering any managed heap allocations.

The Integrated Journey

Architecture Diagram
sequenceDiagram
    participant OS as Network Stack
    participant LP as Buffer Pool (BufferPoolManager)
    participant DC as Dispatch Channel (Sharded)
    participant FR as Frozen Registry (O(1))
    participant CH as Source-Generated Invoker
    participant CP as Context Pool (ObjectPoolManager)

    OS->>LP: Receive raw bytes
    LP-->>OS: Return IBufferLease (Pooled)
    OS->>DC: Push(Lease)
    DC->>DC: Hash connection to shard
    DC->>FR: TryDeserialize(Lease.Span)
    FR-->>DC: IPacket (Pooled Deserialization)
    DC->>CP: Get<PacketContext<T>>()
    CP-->>DC: Context instance (Reset)
    DC->>CH: Invoke Compiled Delegate
    CH->>CH: Execute Application Logic
    CH-->>DC: ValueTask (Already Completed)
    DC->>CP: Return(Context)
    DC->>LP: Dispose(Lease)

1. Efficient Packet Definitions

High performance starts with how you define your data. Use SerializeLayout.Explicit so the framework can use specialized bit-blitting deserializers.

using Nalix.Codec.DataFrames;
using Nalix.Abstractions.Serialization;

[GenerateFormatter]
[SerializePackable(SerializeLayout.Explicit)]
public sealed partial class HighFreqUpdate : PacketBase<HighFreqUpdate>, IFixedSizeSerializable, IPacketStaticOpcode
{
    public static ushort StaticOpCode => 0x5001;

    [SerializeOrder(0)] public int EntityId { get; set; }
    [SerializeOrder(1)] public float PositionX { get; set; }
    [SerializeOrder(2)] public float PositionY { get; set; }
}

2. Setup & Compilation

Nalix “bakes” your handlers and packet lookups during startup, handled automatically by NetworkApplicationBuilder:

  1. Direct-Indexed Registry — PacketRegistry uses source-generated metadata (PacketRegistryGenerator) to populate a direct-indexed PacketDeserializer?[65536] table (s_table[opcode]) and composed fast-dispatch delegates for O(1), zero-allocation lookups.
  2. Source-Generated Dispatch — PacketHandlerGenerator emits zero-allocation invoker delegates at compile time, eliminating reflection overhead.
using Nalix.Hosting;

var app = NetworkApplication.CreateBuilder()
    .MapHandlers<GameController>() // invokers generated at build time
    .Build(); // lookups frozen at startup

If you’re not using the hosting layer, populate dispatch options manually:

var channel = new PacketDispatchChannel(options =>
{
    options.WithHandler(() => new MyController());
});

The compiler transforms your handler method into a static delegate conceptually like this:

public static ValueTask<object?> CompiledInvoker(object? instance, PacketContext<HighFreqUpdate> ctx)
    => ((MyController)instance!).HandleUpdate(ctx);

This delegate is cached in a FrozenDictionary, giving O(1) lookup with lower overhead than a standard Dictionary.

3. The Pooling Pipeline

Buffer leasing

Incoming data is stored in a BufferLease backed by ArrayPool<byte>.Shared through BufferPoolManager with power-of-two bucket alignment and a 1-byte diagnostic sentinel (0xA5) to detect buffer overruns when leases are returned. BufferLease shells are themselves pooled via a lock-free free-list with an O(1) atomic counter.

using BufferLease lease = BufferLease.Rent(1024);
// use lease.Span for zero-copy slicing

Constructing outgoing packets

When sending a packet, don’t use new byte[] — rent a lease, serialize into it, and send the memory slice:

[PacketOpcode(0x5002)]
public async ValueTask SendResponse(IPacketContext<MyPacket> context)
{
    var response = new MyResponse { Status = 200 };

    using var lease = BufferLease.Rent(response.Length);
    int written = response.Serialize(lease.SpanFull);
    lease.CommitLength(written);

    await context.Connection.TCP.SendAsync(lease.Memory);
}

Object pooling (zero-lock / zero-allocation)

Nalix pools incoming context metadata and other hot-path objects using a hybrid ObjectPool model:

  • Thread-Local Lock-Free Cache — ThreadLocalCache<T> bypasses the central pool, saving/retrieving on the same thread with zero synchronization overhead.
  • Flat Index ID Resolution — compile-time type IDs (PoolType<T>.Id) index directly into a pre-allocated array of pools, eliminating locks, scans, or hash computation on generic type lookups.

This ensures renting and returning objects executes in ~22 ns with 0 B allocated.

Pattern: high-performance handler

To keep the path zero-allocation, your handler must:

  1. Accept IPacketContext<T> — uses the pooled context and the (potentially) struct-based packet.
  2. Complete synchronously where possible — if you must await, only await a ValueTask/Task you know is already completed.
  3. Avoid closures — lambdas that capture local variables allocate a closure object.
[PacketOpcode(0x5001)]
public ValueTask HandleUpdate(IPacketContext<HighFreqUpdate> context)
{
    var packet = context.Packet;
    GlobalState.UpdateEntity(packet.EntityId, packet.PositionX, packet.PositionY);
    return ValueTask.CompletedTask;
}

Pattern: the ultra hot path (custom IProtocol raw memory bypass)

For packets where even bit-blitting deserialization is too costly (streaming media, encrypted proxy traffic), bypass PacketDispatchChannel and PacketRegistry inside your Protocol.ProcessMessage(...) implementation and operate directly on args.Lease.Span:

public override void ProcessMessage(object? sender, IConnectionEventArgs args)
{
    ReadOnlySpan<byte> raw = args.Lease.Span;
    if (raw.AsHeaderRef().OpCode == 0x7001)
    {
        // Points directly to the pooled IBufferLease.Span with zero deserialization.
        ProcessRawStream(raw, args.Connection);
        return;
    }

    _dispatch.HandlePacket(args.Lease, args.Connection);
}

4. Fair Concurrency & Priority Management

To process thousands of concurrent connections without one high-volume packet type (like movement updates) monopolizing CPU, Nalix uses a sharded, priority-aware dispatch system.

Thread affinity: all packets from a single connection are processed sequentially on the same core, avoiding races without locks. Parallelism: different connections spread across all available cores.

builder.ConfigureDispatchOptions(options => {
    // Match shards to CPU cores (default: Environment.ProcessorCount)
    options.WithDispatchLoopCount(Environment.ProcessorCount);
    // Increase the budget of packets processed per core wake-up
    options.Drain.MaxDrainPerWakeMultiplier = 12;
});

Nalix also implements Priority-Weighted per-connection scheduling across 5 priority queues (URGENT, HIGH, MEDIUM, LOW, NONE): if a client floods the server with low-priority packets, high-priority packets still get processed first.

[GenerateFormatter]
[SerializePackable(SerializeLayout.Explicit)]
public sealed partial class UrgentAlert : PacketBase<UrgentAlert>, IPacketStaticOpcode
{
    public static ushort StaticOpCode => 0x9001;

    public UrgentAlert()
    {
        Priority = PacketPriority.URGENT;
    }
}

Configure per-connection backpressure and drop policy via DispatchOptions ([Dispatch] section in assets/config.ini):

[Dispatch]
MaxPerConnectionQueue = 1024
DropPolicy = DropNewest
BlockTimeout = 00:00:01

5. Zero-Allocation Error Handling

Exception handling can be expensive. Standard exceptions are costly due to stack trace generation — Nalix caches common transport exceptions (ConnectionReset, SendFailed, MessageTooLarge, UdpPayloadTooLarge, UdpPartialSend, UdpSendFailed) as static readonly fields via the Throw class, overriding StackTrace to bypass the expensive stack crawl. Throw.GetSocketError(SocketError) returns a cached SocketException for standard OS errors.

Prefer returning a result object over throwing for business-logic failures:

public ValueTask<LoginResult> HandleLogin(LoginRequest request)
{
    if (!Valid(request))
        return ValueTask.FromResult(LoginResult.InvalidCredentials);
    // ...
}

Instead of per-packet try-catch, register a global observer — called only when a handler throws:

using Nalix.Hosting;

builder.ConfigureDispatchOptions(options =>
{
    options.WithErrorHandling((exception, opCode) =>
    {
        Logger.Error($"OpCode 0x{opCode:X4} failed: {exception.Message}");
        Metrics.HandlerErrors.WithLabels(opCode.ToString()).Inc();
    });
});

Every connection tracks its own error count — Nalix calls connection.IncrementErrorCount() whenever a handler throws. Monitor this in middleware to disconnect unstable clients without extra allocations:

public sealed class HealthGuardMiddleware : IPacketMiddleware<IPacket>
{
    public async ValueTask InvokeAsync(IPacketContext<IPacket> context, Func<CancellationToken, ValueTask> next)
    {
        if (context.Connection.ErrorCount > 10)
        {
            context.Connection.Disconnect("Protocol violation threshold exceeded.");
            return;
        }
        await next(context.CancellationToken);
    }
}

6. SIMD-Optimized Primitives

Zero-allocation extends to cryptographic primitive checks. byte[] arrays allocate heap memory and require slow sequential comparisons. Nalix implements custom value types like Bytes32 for strict 256-bit payloads (session secrets, X25519 keys, handshake hashes), using AVX2/SSE2 hardware intrinsics for O(1) comparisons directly on CPU registers:

[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public readonly bool Equals(Bytes32 other)
{
    if (Avx2.IsSupported)
    {
        // 256-bit AVX2 hardware acceleration — compares 32 bytes in a single CPU cycle
        Vector256<byte> v = Unsafe.ReadUnaligned<Vector256<byte>>(ref a);
        Vector256<byte> o = Unsafe.ReadUnaligned<Vector256<byte>>(ref b);
        // ...
    }
}

This makes core security checkpoints (like comparing HMAC MAC proofs during session resumption) execute in fractions of a nanosecond, immune to timing side-channels and garbage collection.

Verifying Zero-Allocations

Verify a block of code allocates nothing in unit or integration tests:

long startingBytes = GC.GetAllocatedBytesForCurrentThread();
await RunLoadTestAsync(); // e.g. dispatch 10,000 packets
long allocated = GC.GetAllocatedBytesForCurrentThread() - startingBytes;
Assert.Equal(0, allocated);

Or use BenchmarkDotNet’s MemoryDiagnoser to confirm handlers are truly “green” (0 B allocated):

[MemoryDiagnoser]
public class ProtocolBenchmarks
{
    [Benchmark]
    public async ValueTask HandlePacket()
        => await _dispatch.ExecutePacketHandlerAsync(_testPacket, _mockConnection);
}

Advanced Monitoring

  • Buffer Pool Health (BufferPoolManager) — Enable BufferOptions.EnableBufferLeakDetection in staging to catch any unreturned BufferLease instances exceeding SuspiciousThresholdSeconds.
  • Dispatch Health (PacketDispatchChannel) — high WakeSignals relative to processed packets suggests efficient batching; a growing Ready Connections count means handlers are too slow or DispatchLoopCount is too low.
  • CLI monitoring — dotnet-counters monitor -p <PID> --counters Nalix.Framework,System.Runtime[alloc-rate,gen-0-gc-count].

Summary Checklist

  • Use struct or pooled class for packets.
  • Use IPacketContext<T> to leverage frame-level pooling.
  • Annotate controllers with [PacketController].
  • Use [PacketOpcode] for zero-reflection routing.
  • Use SIMD primitives (Bytes32) for security checks.
  • Return ValueTask from handlers.
  • Avoid new, LINQ, and closures inside handlers.
  • Use Throw for zero-allocation exception propagation.
  • Verify with BenchmarkDotNet [MemoryDiagnoser].