Operations 13 min read

How CPU Affinity Boosts Low‑Latency in High‑Frequency Trading

The article explains why the Linux scheduler’s thread migration hurts deterministic low‑latency trading, outlines three reasons to bind threads to specific cores, demonstrates how to use taskset and Rust’s core_affinity crate for binding, and shows how to verify the binding with htop and command‑line tools.

Rust High-Frequency Quantitative Trading
Rust High-Frequency Quantitative Trading
Rust High-Frequency Quantitative Trading
How CPU Affinity Boosts Low‑Latency in High‑Frequency Trading

Why High‑Frequency Trading Needs Core Binding

The default Linux CFS scheduler aims for fairness and throughput, moving runnable threads among idle cores. For a hot‑path strategy thread this causes cache and TLB cold misses and unpredictable migration overhead, both of which add tens to hundreds of cycles of jitter that inflate tail latency.

Binding threads to cores solves three problems: (1) it keeps the thread’s hot data in the core’s L1/L2 caches and TLB, (2) it eliminates the random cost of migration, and (3) it lets you plan a core layout where specific cores run the strategy, market data, I/O, etc., without contention.

Binding with taskset

taskset

(from util‑linux) accepts a CPU affinity mask to restrict a process to certain cores without code changes. Example commands:

# Bind the whole process to CPU 3
taskset -c 3 ./my_strategy

# Bind to a set of cores: 2,4,5,6
taskset -c 2,4-6 ./my_strategy

For a running process, use -p (or --pid) to query or change its mask. Note that taskset -p only changes the thread you specify; to affect all threads of a multithreaded process add -a (or --all-tasks).

# Change the affinity of PID 12345 to CPU 3
taskset -cp 3 12345

# Change all threads of PID 12345 to CPU 3
taskset -acp 3 12345

The -c option accepts comma‑separated lists and ranges (e.g., 0,5,7,9-11) and even stepped ranges like 0-10:2, which are more readable than hexadecimal masks.

Binding Inside Rust with core_affinity

Add the crate:

[dependencies]
core_affinity = "0.8"

Bind the current thread to a core:

// Bind the current thread to core 3
let core = core_affinity::CoreId { id: 3 };
let ok = core_affinity::set_for_current(core);
if !ok {
    eprintln!("Failed to bind to core 3");
}

The implementation creates a cpu_set_t with a single bit set via CPU_SET and calls sched_setaffinity(0, …), where the first argument 0 means “the calling thread”. Thus the function always binds the thread that invokes it.

A typical role‑based layout spawns threads and pins each one before entering its work loop:

use std::thread;

fn pin_current_thread(core_index: usize) {
    let ok = core_affinity::set_for_current(core_affinity::CoreId { id: core_index });
    if !ok { eprintln!("WARNING: failed to pin thread to core {}", core_index); }
}

let strategy = thread::Builder::new()
    .name("strategy".to_string())
    .spawn(move || { pin_current_thread(3); /* hot‑path loop */ })?;
let perf = thread::Builder::new()
    .name("perf-tracker".to_string())
    .spawn(move || { pin_current_thread(6); /* stats collection */ })?;

Core indices can be placed in a configuration file (e.g., strategy_core_index, perf_core_index, io_core_index) so the same binary works on machines with different core counts.

How to Verify the Binding

htop shows both processes and threads. Enable the PROCESSOR column (Setup → Screens → Available Columns) and turn on “Show custom thread names” (Setup → Display options). Then you can see each thread’s current core and name.

Key things to check:

Press F5 (or t) for a tree view; thread names appear under the process.

The CPU column should stay constant for bound threads (e.g., strategy always on core 5, perf‑tracker on core 4).

The per‑core load bars at the top of htop should show the bound cores busy while others stay idle.

Command‑line checks:

# Show the affinity mask of a process
taskset -cp 12345
# Show the affinity mask of a specific thread (TID)
taskset -cp 12360

# Show which core each thread is currently running on
ps -T -o tid,psr,comm -p 12345

The affinity list ( taskset -cp) must contain only the single core you assigned, and the psr field must match that core, confirming the binding is effective.

You can also read /proc/<pid>/task/<tid>/status to get Cpus_allowed_list for scripting health checks.

Beyond Binding: Full Core Isolation

Binding keeps your strategy thread on a core, but the scheduler can still place other processes, kernel threads, or hardware interrupts on that core, re‑introducing tail‑latency spikes. To truly reserve a core you need process isolation with kernel boot parameters such as isolcpus (remove the core from the scheduler’s view) and nohz_full (disable periodic timer interrupts), and move IRQ affinity away from the isolated core. Those topics will be covered in a future article.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Rustperformance tuningLinux schedulinghtophigh-frequency tradingCPU affinitytaskset
Rust High-Frequency Quantitative Trading
Written by

Rust High-Frequency Quantitative Trading

Rust High-Frequency Quantitative Trading System

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.