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.
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_strategyFor 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 12345The -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 12345The 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Rust High-Frequency Quantitative Trading
Rust High-Frequency Quantitative Trading System
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
