DispatchCode
Utente Èlite
- Messaggi
- 2,493
- Reazioni
- 2,040
- Punteggio
- 134
x86 split lock detection: cos'è?
Nota Importante: più volte ripeterò CPU invece di core, per semplicità.
Durante il boot, nella fase iniziale - se la CPU supporta la split lock detection e tra i kernel param non è presente
split_lock_detect=off - compare questo messaggio visualizzato da dmesg:
Codice:
[ 0.000000] [ T0] x86/split lock detection: #AC: crashing the kernel on kernel split_locks and warning on user-space split_locks
Non so se l'avete mai notato e in caso, se poi vi siate chiesti cosa fosse.
Procediamo per step, la prima cosa che si vede è che questa feature (split lock detection), che è per x86. La seconda è #AC: chi ha familiarità con x86_64 (e con il manuale di Intel) magari sa che vengono indicate in questo modo le exceptions / traps; in questo caso si tratta di Alignment Check.
E' un'eccezione lanciata dalla CPU che riguarda l'allineamento in memoria di un dato: quando questo si trova su due cache line distinte, viene lanciata un'eccezione. Notare che in altre architetture non è nemmeno possibile accedere a dati non allineati.
Let's take a step back: operazioni atomiche
Avrei dovuto titolarlo "operazioni che sembrano atomiche", visto l'esempio che sto per fare.
Consideriamo una semplicissima operazione come un incremento di una variabile, num++;
In x86_64 ciò che viene generato è:
Codice:
addl $1, %eax
Questa operazione non è atomica, anche se lo sembra.
L'operazione richiede un accesso in memoria per prelevare il dato (dalla locazione [rbp-4]), l'incremento del valore, e il successivo salvataggio allo stesso indirizzo.
Esempio:
C:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define NUM_THREADS 4
#define NUM_PER_THREADS 100
int counter = 0;
void* thread_function(void *c) {
for(int i = 0; i < NUM_PER_THREADS; i++)
counter++;
}
int main() {
pthread_t threads[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
if (pthread_create(&threads[i], NULL, thread_function, NULL) != 0) {
perror("Thread creation failed");
return 1;
}
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("Final counter value: %d\n", counter);
printf("Expected value: %d\n", NUM_THREADS * NUM_PER_THREADS);
return 0;
}
Eseguendolo 10 volte, abbiamo:
Codice:
marco@linux:~> seq 10 | xargs -I {} ./thread.o
Final counter value: 326
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 344
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 300
Expected value: 400
Final counter value: 327
Expected value: 400
Final counter value: 400
Expected value: 400
Può succedere infatti in un sistema concorrente che due diverse CPU che stanno eseguendo lo stesso codice finiscano per sovrascrivere il dato: questo tipo di operazione prende il nome di RMW, read modify write, che richiede appunto una lettura dalla memoria, la modifica del valore e il successivo salvataggio.
Si può ovviare a questa situazione evitando anche mutex / semafori, usando "atomic":
C:
static void inc() {
ex.counter = 0;
atomic_fetch_add(&ex.counter, 1);
}
Codice:
lock addl $1, ex+64(%rip)
Cos'è il lock prefix?
Il lock prefix è effettivamente parte dell'istruzione. Il codice macchina dell'istruzione mostrata sopra è
0f 83 00 01. Il prefisso è codificato all'inizio, 0f.Vine aggiunto per rendere un'istruzione atomica: questo fa si che in casi in cui si ha della memoria condivisa, questa operazione venga appunto eseguita come fosse 1 sola operazione, garantendo quindi che 2 CPU non possono sovrascrivere il dato.
C:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <stdatomic.h>
#define NUM_THREADS 4
#define NUM_PER_THREADS 100
_Atomic int counter = 0;
void* thread_function(void *c) {
for(int i = 0; i < NUM_PER_THREADS; i++)
atomic_fetch_add(&counter,1);
}
int main() {
pthread_t threads[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
if (pthread_create(&threads[i], NULL, thread_function, NULL) != 0) {
perror("Thread creation failed");
return 1;
}
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("Final counter value: %d\n", counter);
printf("Expected value: %d\n", NUM_THREADS * NUM_PER_THREADS);
return 0;
}
Codice:
marco@linux:~> seq 10 | xargs -I {} ./thread.o
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Final counter value: 400
Expected value: 400
Il funzionamento di LOCK non è esattamente lo stesso identico di un tempo. In sintesi, LOCK fa si che il bus della CPU venga effettivamente "lockato" in modo tale che altre CPU non possano utilizzarlo.
Questo implica chiaramente che le altre CPU dovranno attendere prima di effettivamente poter leggere il dato dalla memoria. La conseguenza è una certa inefficienza in quanto viene bloccato l'intero memory bus. Nelle CPU moderne la situazione è differente, se non vado errato non viene più "alzato" il segnale #LOCK su uno dei pin della CPU (penso anche da parecchio).
E' qui che nasce il cache locking.
Intel introdusse un protocollo chiamato MESI (Modified, Exclusive, Shared, Invalid) per mantenere la cache coherency. Per non scendere in dettagli, questo protocollo in sostanza fa si che una CPU non andrà a modificare solo la propria cache, ma effettuerà una modifica alle cache delle altre CPU (prende il nome di snooping, questa azione). In questo modo tutte le cache saranno aggiornate con il dato presente nella memoria centrale.
A quanto ho capito la richiesta avviene tramite messaggi di broadcast direttamente sui bus delle CPU (del tipo, CPU0 a CPU1); la richiesta si chiama RFO (Request For Ownership) e consente di passare dallo stato Shared allo stato Invalid: la conseguenza è che la CPU che ha fatto la richiesta avrà il proprio risultato inviato in broadcast alle altre cache.
Quindi non viene più bloccato l'intero bus, ma vi è un arbitraggio (è anche impossibile, secondo MESI, che due CPU inviino l'un l'altra il segnale per invalidare la cache: una sola delle due la vedrà invalidata secondo l'arbitraggio a livello di cache coherency).
MESI si occupa di garantire che le CPU non abbiano "stale data", sincronizzando i dati condivisi nelle cache (così che la copia aggiornata sia presente in tutte).
Il LOCK prefix è molto importante per risolvere la problematica vista sopra, quando più thread possono modificare un dato: in questo caso infatti MESI non può farci molto; se due thread si sovrascrivono il dato, la situazoine non cambia; LOCK fa in modo che questo non avvenga.
[Non scendo in ulteriori dettagli poichè non ho avuto modo di refreshare le conoscenze su MESI.]
Il LOCK prefix però non fa miracoli e diventa sicuramente meno efficiente quando si incappa in un dato che è spezzato su due cache line...
Split Lock: di che si tratta?
Riprendendo le prime righe:
E' un'eccezione lanciata dalla CPU che riguarda l'allineamento in memoria di un dato: quando questo si trova su due cache line distinte, viene lanciata un'eccezione.
Uno split lock si verifica quando un dato si trova a cavallo di due cache lines. Quando si verifica, il bus lock (globale) si rende necessario.
Queste sono alcune statistiche raccolte - sommariamente, non ho fatto molte run - senza split lock (togliendo packed) e con lo splitlock, da notare cycles:
Codice:
355.121 cycles:u # 1,496 GHz
Codice:
405.225 cycles:u # 1,413 GHz
Nota: non ho riportato anche "time elapsed" in quanto in questo caso è ingannevole, per così dire: il kernel penalizza l'applicazione che sta causando split-lock se
split_lock_mitigate=1 (bus_lock.c:251). Se eseguite qualche prova, tenetelo presente (sysctl kernel.split_lock_mitigate).E' possibile vedere la dimensione di 1 cache line in Linux con:
Bash:
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64
La dimensione è espressa in bytes.
Normalmente il compilatore fa in modo che i dati siano tutti allineati e non si trovino su 2 cache line. Non è così difficile comunque ritrovarsi nella situazione opposta se si deve far uso di "packed" structure: sono delle struct (in C o C++) che fanno uso di un attributo del compilatore chiamato "packed".
E' comunemente usato in determinati contesti questo attributo, poichè consente di non avere "buchi" (padding) tra un membro e l'altro di una struct. In questo contesto lo utilizzo proprio per "provocare" uno split-lock.
Esempio pratico:
C:
struct Example {
int64_t buffer1[7];
uint8_t buffer2[6];
uint32_t value;
}__attribute((packed));
C:
marco@linux:~> pahole thread.o
struct Example {
int64_t buffer1[7]; /* 0 56 */
uint8_t buffer2[6]; /* 56 6 */
uint32_t value; /* 62 4 */
/* size: 66, cachelines: 2, members: 3 */
/* last cacheline: 2 bytes */
} __attribute__((__packed__));
In questo esempio si può vedere che "value", numero a 32bit, inizia al byte 62, ma essendo lungo 4bytes, viene spezzato andando nell'altra cache line.
Senza packed, la situazione è invece molto differente:
C:
struct Example {
int64_t buffer1[7]; /* 0 56 */
uint8_t buffer2[6]; /* 56 6 */
/* XXX 2 bytes hole, try to pack */
/* --- cacheline 1 boundary (64 bytes) --- */
_Atomic uint32_t counter; /* 64 4 */
/* size: 72, cachelines: 2, members: 3 */
/* sum members: 66, holes: 1, sum holes: 2 */
/* padding: 4 */
/* last cacheline: 8 bytes */
};
2 cachelines come sopra, con la differenza che ora c'è del padding
In caso di utilizzo di LOCK, andremo a bloccare il bus della CPU; questo avviene per garantire comunque coerenza del dato, che ora si trova su 2 cache line.
C:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdatomic.h>
struct Example {
int64_t buffer1[7];
uint8_t buffer2[6];
_Atomic uint32_t counter;
} __attribute((packed));
struct Example ex;
static void inc() {
ex.counter = 0;
atomic_fetch_add(&ex.counter, 1);
}
int main() {
inc();
printf("Final counter value: %d\n", ex.counter);
return 0;
}
Output:
Codice:
marco@linux:~> ./thread.o
Final counter value: 1
Guardando dmesg, si vede:
Codice:
marco@linux:~> dmesg -T
[ven lug 18 20:34:21 2025] [ T488105] x86/split lock detection: #AC: thread.o/488105 took a split_lock trap at address: 0x401144
Commentando l'attributo "packed" e ricompilando, la situazione sarà la seguente:
Output
Codice:
marco@linux:~> ./thread.o
Final counter value: 1
dmesg:
Codice:
marco@linux:~> dmesg -T
[ven lug 18 20:34:21 2025] [ T488105] x86/split lock detection: #AC: thread.o/488105 took a split_lock trap at address: 0x401144
il messaggio è lo stesso identico di prima: il che vuol dire che non è stato triggerato nuovamente #AC.
Se proviamo a rimettere packed e a ricompilare:
Codice:
marco@linux:~> dmesg -T
[ven lug 18 20:34:21 2025] [ T488105] x86/split lock detection: #AC: thread.o/488105 took a split_lock trap at address: 0x401144
[ven lug 18 20:39:01 2025] [ T492082] x86/split lock detection: #AC: thread.o/492082 took a split_lock trap at address: 0x401144
ecco che compare nuovamente il messaggio!
x86/split lock detection: da dove viene stampato?
A proposito... da dove esce quel messaggio li sopra?
Arriva da qui:
https://elixir.bootlin.com/linux/v6.15.6/source/arch/x86/kernel/traps.c#L425 (exc_alignment_check)
Come detto all'inizio, si tratta di un'eccezione, Alignment Check.
Come ogni trap / exception questa è presente in una tabella, chiamata IDT. In questo caso l'indice è il numero 17.
Ho una versione compilata di Linux, usando vng per fare il boot, ho poi lanciato la crash utility:
Codice:
crash> p -x idt_table[17]
$1 = {
offset_low = 0x11f0,
segment = 0x10,
bits = {
ist = 0x0,
zero = 0x0,
type = 0xe,
dpl = 0x0,
p = 0x1
},
offset_middle = 0xb6c0,
offset_high = 0xffffffff,
reserved = 0x0
}
Molto sinteticamente: il sistema operativo inizializza la IDT (per ogni CPU) al boot; l'handler è spezzato in 3 differenti offset.
Ricomponendoli, abbiamo:
Codice:
crash> dis 0xffffffffb6c011f0
0xffffffffb6c011f0 <asm_exc_alignment_check>: endbr64
0xffffffffb6c011f4 <asm_exc_alignment_check+4>: clac
0xffffffffb6c011f7 <asm_exc_alignment_check+7>: cld
0xffffffffb6c011f8 <asm_exc_alignment_check+8>: call 0xffffffffb6c01c40 <error_entry>
0xffffffffb6c011fd <asm_exc_alignment_check+13>: mov %rax,%rsp
0xffffffffb6c01200 <asm_exc_alignment_check+16>: mov %rsp,%rdi
0xffffffffb6c01203 <asm_exc_alignment_check+19>: mov 0x78(%rsp),%rsi
0xffffffffb6c01208 <asm_exc_alignment_check+24>: movq $0xffffffffffffffff,0x78(%rsp)
0xffffffffb6c01211 <asm_exc_alignment_check+33>: call 0xffffffffb7c4fd20 <exc_alignment_check>
0xffffffffb6c01216 <asm_exc_alignment_check+38>: jmp 0xffffffffb6c01d80 <error_return>
Tornando al messaggio iniziale, in apertura:
Codice:
[ 0.000000] [ T0] x86/split lock detection: #AC: crashing the kernel on kernel split_locks and warning on user-space split_locks
Questo viene stampato da qui: https://elixir.bootlin.com/linux/v6.15.4/source/arch/x86/kernel/cpu/bus_lock.c#L390 (riga 402).
Da notare la azioni che vengono intraprese quando #AC si verifica (https://elixir.bootlin.com/linux/v6.15.6/source/arch/x86/kernel/traps.c#L425):
C:
432 if (!user_mode(regs))
433 die("Split lock detected\n", regs, error_code);
434
435 local_irq_enable();
436
437 if (handle_user_split_lock(regs, error_code))
438 goto out;
439
440 do_trap(X86_TRAP_AC, SIGBUS, "alignment check", regs,
441 error_code, BUS_ADRALN, NULL);
Se uno split_lock si verifica in kernel space (riga 432), viene direttamente eseguito die().
Se è in user space, come nel nostro caso:
C:
317 bool handle_user_split_lock(struct pt_regs *regs, long error_code)
318 {
319 if ((regs->flags & X86_EFLAGS_AC) || sld_state == sld_fatal)
320 return false;
321 split_lock_warn(regs->ip);
322 return true;
323 }
Se il flag AC in EFLAGS / RFLAGS è settato (viene settato sul singolo task), allora non da il warn (e genera un fault); al contrario, nel nostro caso non è settato, questo genera il messaggio di warning che abbiamo visto prima.
Condivido al momento questo articolo, in rete è possibile approfondire tutto il resto (ad iniziare da MESI): Linux Adding New Control Since Its Splitlock Detector Is Wrecking Some Steam Play Games (dove viene menzionato quanto riportato a proposito della "penalizzazione", citata sopra).
PS. ho scritto tutto in serata, ho anche riletto un paio di volte integrando informazioni. Se trovate errori, fatemeli notare (avendo pietà di me
