GUIDA Split-lock detection in Linux: cos'è?

Pubblicità

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);
}
L'assembly generato questa volta è:
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 😇).
 
Ottimo articolo, cose che non si trovano facilmente qua e la, ne il ragionamento complessivo specie in italiano.


Discorso vale solo per x86 (32 bit) ? Non trovo la linea nel messaggio di boot ain amd x86_64, ma forse si perde nell'initramfs.

in arch/x86/include/asm/cpufeatures.h
#define X86_FEATURE_SPLIT_LOCK_DETECT (11*32+ 6)

In x86_64 ciò che viene generato è:
addl $1, %eax
Mi sarei aspettato "inc %eax" meno costosa di un add, nel senso, nei primi x86 un add richiedeva diversi cicli di clock, mentre inc forse uno solo. (da verificare), Ttuttavia, dovrebbero entrambi essere atomiche. Mentre si, l'insieme della lettura dalla cache in eax, incremento e riscrittura in menoria non e' atomico.
 
Ottimo articolo, cose che non si trovano facilmente qua e la, ne il ragionamento complessivo specie in italiano.
Grazie bigendian!

Discorso vale solo per x86 (32 bit) ? Non trovo la linea nel messaggio di boot ain amd x86_64, ma forse si perde nell'initramfs.
No vale anche per x86_64 (Io ho fatto tutto su un 64bit).

È una delle primissime voci comunque.

in arch/x86/include/asm/cpufeatures.h
#define X86_FEATURE_SPLIT_LOCK_DETECT (11*32+ 6)
Quindi in teoria da lscpu o /proc/cpuinfo dovresti vedere split_lock_detect tra i flags, giusto? Verifica se lo vedi. Che versione del kernel usi?

Verifica se hai per caso hai un kernel parameter che lo disabilita anche.

Mi fa pensare che non entri nello switch, se il resto è ok: https://elixir.bootlin.com/linux/v6.15.4/source/arch/x86/kernel/cpu/bus_lock.c#L396

Mi sarei aspettato "inc %eax" meno costosa di un add, nel senso, nei primi x86 un add richiedeva diversi cicli di clock, mentre inc forse uno solo. (da verificare), Ttuttavia, dovrebbero entrambi essere atomiche. Mentre si, l'insieme della lettura dalla cache in eax, incremento e riscrittura in menoria non e' atomico.

Si ha meno cicli, ma penso sia stata usata per via di questo:

1000017664.webp
 
Interessaante, No non ho quel flag in cpu info.

Ho un kernel mainline compilato da me
❯ cat /proc/version
Linux version 6.14.0-rc3-yamato-devel-00138-g4370a50eb7b6 (angelo@yamato) (gcc (GCC) 14.2.1 20250207, GNU ld (GNU Binutils) 2.44) #44 SMP PREEMPT_DYNAMIC Thu Feb 20 21:51:52 CET 2025

❯ cat /proc/cmdline
root=UUID=f946efca-0772-4336-a76c-eaa3e2b4f6d0 rw nouveau.debug=info nouveau.config=NvGspRm=1 nouveau.runpm=0 init=/usr/local/bin/sysghost initrd=\initramfs-6.14.0-rc3-yamato-devel-00138-g4370a50eb7b6.img

Vedo che bus_lock.c e' compilato con CONFIG_X86_BUS_LOCK_DETECT che e' a "y" quindi sld_setup() dovrebbe essere eseguita. Mmm sospetto che sia un messaggio visibile solo da initramfs.

❯ zcat /proc/config.gz | grep X86_BUS_LOCK_DETECT
CONFIG_X86_BUS_LOCK_DETECT=y
 
Interessaante, No non ho quel flag in cpu info

Uhm, interessante questo.
Che CPU hai esattamente?

Verifica che non venga magari stampato quel "disabled" che vedi nello switch (in teoria è settato su warn per il tipo di config che vedi sopra).

Non so, l'unica altra cosa che mi viene in mente su due piedi, è che hai un log level che filtra i pr_info() e mostra solo cose più critiche, però mi sembrerebbe strano (la butto lì giusto come idea); credo che il default comprenda i pr_info() anche.

La cosa che mi sembra molto più probabile è che la CPU non supporti lo split lock detection.
 
Eccomi, tornato dalle ferie, sto guardando un po' in sta cosa nel mo AMD Ryzen 9 3900XT

In arch/x86 troviamo
Codice:
x86/kernel/cpu/bus_lock.c:      if ((boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT)
x86/include/asm/cpufeature.h:#define boot_cpu_has(bit)  cpu_has(&boot_cpu_data, bit)

boot_cpu_data (con le capabilities) in alcune architetture e' riempito in un cpu probe. Ma non in x86, credo venga dal bios. Dopo provo a vedere se il bios ha qualche opzione a riguardo.
 
Eccomi, tornato dalle ferie, sto guardando un po' in sta cosa nel mo AMD Ryzen 9 3900XT

In arch/x86 troviamo
Codice:
x86/kernel/cpu/bus_lock.c:      if ((boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT)
x86/include/asm/cpufeature.h:#define boot_cpu_has(bit)  cpu_has(&boot_cpu_data, bit)

boot_cpu_data (con le capabilities) in alcune architetture e' riempito in un cpu probe. Ma non in x86, credo venga dal bios. Dopo provo a vedere se il bios ha qualche opzione a riguardo.

Arrivo anche io un pò in ritardo, causa lavoro.

Mi sa che quello che cerchi si trova in:

Codice:
setup_arch
    early_cpu_init
        early_identify_cpu
            sld_setup

boot_cpu_data in x86 viene passato a early_identify_cpu.
sld_setup() si occupa di bus e split lock detection. Sarebbe interessante mettere qualche pr_info() per vedere esattamente dove si arriva, però mi aspetto che non sia supportato il bus lock così come lo split lock, altrimenti si vedrebbe qualche tipo di messaggio.

Anche la mia CPU (sul pc desktop) comunque pare non lo supporti, è un Intel i9-10900KF.
 
Pubblicità
Pubblicità
Indietro
Top