Bagaimana dan mengapa kami mengoptimalkan algoritma untuk membersihkan cache SLAB di kernel Linux

Meningkatnya popularitas kontainer dan penggunaannya bersama dengan kelompok kontrol mengungkapkan masalah skalabilitas yang serius, yang mengarah pada penurunan kinerja yang signifikan pada alat berat besar. Masalahnya adalah bahwa waktu pintas dari cache SLAB tergantung secara kuadrat pada jumlah kontainer, dan konsumsi aktif sejumlah besar memori dalam waktu singkat dapat menyebabkan sistem masuk ke loop sibuk, menghabiskan 100% dari waktu prosesor. Hari ini saya ingin memberitahu Anda bagaimana kami memecahkan masalah ini dengan mengubah algoritma akuntansi untuk menggunakan grup kontrol memcg untuk menggunakan objek cache SLAB dan mengoptimalkan fungsi shrink_slab ().

Pembersihan memori

Mengapa pertanyaan tentang optimasi proses di kernel muncul? Semuanya berawal dari fakta bahwa salah satu pelanggan kami, yang secara aktif menggunakan wadah dan kelompok kontrol memori (memcg), menarik perhatian pada puncak aneh konsumsi sumber daya prosesor yang terjadi dari waktu ke waktu. Beban sistem normal adalah sekitar 50%, dan pada waktu puncak 100% dari waktu prosesor diambil, dan hampir semuanya dikonsumsi oleh kernel (waktu sistem).
Node itu sendiri multi-pengguna, dan sekitar 200 kontainer OpenVZ diluncurkan di sana. Analisis menunjukkan bahwa sejumlah besar pengguna membuat wadah Docker bersarang dan hierarki multi-level grup kontrol memori. Setiap wadah tingkat atas tingkat pengguna berisi sekitar 20 titik pemasangan dan 20 kelompok memori kontrol (memcg) yang dibuat oleh systemd. Selain itu, ada mount poin dan grup kontrol yang dibuat oleh Docker tersebut. Sederhananya, node itu sangat dimuat, dan beban di atasnya jauh lebih kuat daripada rata-rata untuk semua pelanggan kami yang lain. Kami tertarik untuk menemukan alasan munculnya puncak ini, karena masalah yang sama dapat muncul pada mesin yang kurang sibuk, di mana itu hampir tidak terlihat (misalnya, memberikan puncak pada waktu sistem 5%, yang menurunkan kinerja).

Dengan memanipulasi perf, saya berhasil menangkap puncak dan menghapus jejak. Ternyata sebagian besar waktu prosesor dihabiskan untuk membersihkan cache SLAB, yaitu cache super block:

- 100,00% 0,00% kswapd0 [kernel.vmlinux] [k] kthread - 99,31% balance_pgdat - 82,11% shrink_zone - 61,69% shrink_slab - 58,29% super_cache_count + 54,56% list_lru_count_one 


Di sini ada baiknya membuat penjelasan dan membahas masalah ini secara lebih rinci. Semua orang tahu bahwa kernel melakukan cache data yang tidak digunakan untuk sementara waktu sebelum akhirnya membebaskan memori. Kernel menggunakan prinsip ini secara ekstensif. Misalnya, halaman cache berisi halaman-halaman data yang berkaitan dengan file, yang sangat mempercepat akses berulang ketika membaca (karena Anda tidak perlu mengakses disk lagi). Dalam kasus kami, masalah muncul dengan cache metadata superblok yang terkandung dalam dua daftar LRU: s_dentry_lru dan s_inode_lru.

LRU (Setidaknya Terakhir Digunakan)

struct lru_list menunjuk ke array daftar yang ditautkan, dan setiap memcg aktif sesuai dengan satu elemen (list_lru_one) dalam array ini. Ketika objek SLAB tertentu tidak lagi digunakan oleh kernel, kernel menambahkannya ke salah satu daftar array terkait (tergantung pada memcg mana objek milik, atau, secara kasar, yang memcg proses yang digunakan ketika itu menciptakan objek ini). Array itu sendiri dijelaskan sebagai berikut (lru_list :: node :: memcg_lrus):

 struct list_lru_memcg { struct rcu_head rcu; /* array of per cgroup lists, indexed by memcg_cache_id */ struct list_lru_one *lru[0]; /*    */ }; struct list_lru_one { struct list_head list; /*    */ /* may become negative during memcg reparenting */ long nr_items; /*     */ }; 

lru [0] menunjukkan daftar objek yang terkait dengan memcg dengan ID 0;
lru [1] menunjukkan daftar objek yang terkait dengan memcg dengan ID 1;
...
lru [n] menunjukkan daftar objek yang terkait dengan memcg dengan ID n;

LRU mendaftar s_dentry_lru dan s_inode_lru muncul di masalah kita, dan, seperti namanya, mereka berisi objek sistem berkas dentry dan inode yang tidak digunakan.
Di masa depan, jika tidak ada cukup memori dalam sistem atau memcg tertentu, beberapa item daftar akhirnya dibebaskan, dan mekanisme khusus yang disebut shrinker melakukan ini.

Penyusut

Ketika kernel perlu mengalokasikan halaman memori, tetapi tidak ada memori bebas pada NUMA node atau dalam sistem, mekanisme untuk membersihkannya dimulai. Dia mencoba untuk membuang atau membuang sejumlah disk: 1) halaman isi file dari cache halaman; 2) halaman yang terkait dengan memori anonim dalam swap, dan 3) cache objek SLAB (masalah yang kami temui terkait dengan mereka).

Membuang bagian dari objek SLAB yang di-cache tidak secara langsung mempengaruhi rilis halaman: ukurannya, secara umum, lebih kecil dari ukuran halaman, dan satu halaman berisi ratusan objek. Ketika bagian dari objek dibebaskan, celah memori kosong muncul di halaman SLAB, yang dapat digunakan untuk membuat objek SLAB lainnya. Algoritma ini diterima di kernel dengan sengaja: sederhana dan cukup efisien. Pembaca yang tertarik dapat melihat rumus untuk memilih bagian dari objek untuk dibersihkan di fungsi do_shrink_slab ().

Fungsi ini melakukan pembersihan sebenarnya dari beberapa objek, dipandu oleh deskripsi yang diteruskan ke dalam shrink shrink struct:

 static unsigned long do_shrink_slab(struct shrink_control *shrinkctl, struct shrinker *shrinker, int priority) { โ€ฆ /*   */ freeable = shrinker->count_objects(shrinker, shrinkctl); if (freeable == 0) return 0; total_scan = _(freeable); while (total_scan >= batch_size) { /*   */ ret = shrinker->scan_objects(shrinker, shrinkctl); total_scan -= shrinkctl->nr_scanned; } ... } 

Sehubungan dengan superblock shrinker, fungsi-fungsi ini diimplementasikan sebagai berikut. Setiap superblock menyimpan daftar s_dentry_lru dan s_inode_lru miliknya dari objek yang tidak terpakai yang terkait dengannya:

 struct super_block { ... struct shrinker s_shrink; /* per-sb shrinker handle */ ... struct list_lru s_dentry_lru; struct list_lru s_inode_lru; โ€ฆ }; 


Metode .count_objects mengembalikan jumlah objek:

 static unsigned long super_cache_count(struct shrinker *shrink, struct shrink_control *sc) { total_objects += list_lru_shrink_count(&sb->s_dentry_lru, sc); total_objects += list_lru_shrink_count(&sb->s_inode_lru, sc); /*     ) */ total_objects = vfs_pressure_ratio(total_objects); return total_objects; } 


Metode .scan_objects sebenarnya membebaskan objek:

 static unsigned long super_cache_scan(struct shrinker *shrink, struct shrink_control *sc) { /*     s_dentry_lru */ prune_dcache_sb(sb, sc); /*     s_inode_lru */ prune_icache_sb(sb, sc); } 

Jumlah objek yang akan dibebaskan dilewatkan dalam parameter sc. Juga, memcg ditunjukkan di sana, objek yang harus dibuang dari LRU:

 struct shrink_control { int nid; /* ID NUMA  */ unsigned long nr_to_scan; /*   */ struct mem_cgroup *memcg; /* memcg */ }; 

Jadi, prune_dcache_sb () memilih daftar yang ditautkan dari array struct list_lru_memcg :: lru [] dan bekerja dengannya. Prune_icache_sb () melakukan hal yang sama.

Algoritma pintas penyusutan lama

Dengan pendekatan standar, "mengeluarkan" objek dari SLAB tanpa memori di
sc-> target_mem_cgroup terjadi sebagai berikut:

 shrink_node() { โ€ฆ struct mem_cgroup *root = sc->target_mem_cgroup; /*      sc->target_mem_cgroup  */ memcg = mem_cgroup_iter(root, NULL, &reclaim); do { โ€ฆ shrink_slab(memcg, ...); โ€ฆ } while ((memcg = mem_cgroup_iter(root, memcg, &reclaim))); ... } 

Kami melewati semua memcg anak dan memanggil shrink_slab () untuk masing-masing. Selanjutnya, dalam fungsi shrink_slab (), kita melewati semua shrinker dan masing-masing memanggil do_shrink_slab ():

 static unsigned long shrink_slab(gfp_t gfp_mask, int nid, struct mem_cgroup *memcg, int priority) { list_for_each_entry(shrinker, &shrinker_list, list) { struct shrink_control sc = { .nid = nid, .memcg = memcg, }; ret = do_shrink_slab(&sc, shrinker, ...); } } 

Ingatlah bahwa untuk setiap superblok, penyusutnya sendiri ditambahkan ke daftar ini. Mari kita hitung berapa kali do_shrink_slab () akan dipanggil untuk kasing dengan 200 kontainer berisi 20 memcg dan 20 mount poin di masing-masing. Secara total, kami memiliki 200 * 20 titik pemasangan dan 200 * 20 grup kontrol. Jika tidak ada cukup memori di memcg paling atas, kami akan dipaksa untuk mem-bypass semua memcg anaknya (mis., Semuanya secara umum), dan untuk masing-masing dari mereka memanggil masing-masing penyusutan dari shrinker_list. Dengan demikian, kernel akan membuat 200 * 20 * 200 * 20 = 16000000 panggilan ke fungsi do_shrink_slab ().

Selain itu, banyaknya panggilan ke fungsi ini tidak akan berguna: wadah biasanya terisolasi di antara mereka sendiri, dan kemungkinan bahwa CT1 akan menggunakan super_block2 yang dibuat dalam CT2 umumnya rendah. Atau, apa yang sama, jika memcg1 adalah grup kontrol dari CT1, maka elemen yang sesuai dari super_block2-> s_dentry_lru-> node-> memcg_lrus-> lru [memcg1_id] akan menjadi daftar kosong, dan tidak ada gunanya memanggil do_shrink_slab () untuk itu.

Masalah ini dapat dimodelkan menggunakan skrip bash sederhana (data dari patchset, yang kemudian diteruskan ke kernel, digunakan di sini):
 $echo 1 > /sys/fs/cgroup/memory/memory.use_hierarchy $mkdir /sys/fs/cgroup/memory/ct $echo 4000M > /sys/fs/cgroup/memory/ct/memory.kmem.limit_in_bytes $for i in `seq 0 4000`; do mkdir /sys/fs/cgroup/memory/ct/$i; echo $$ > /sys/fs/cgroup/memory/ct/$i/cgroup.procs; mkdir -ps/$i; mount -t tmpfs $is/$i; touch s/$i/file; done 

Mari kita lihat apa yang terjadi jika Anda memanggil prosedur reset cache 5 kali berturut-turut:
 $time echo 3 > /proc/sys/vm/drop_caches 

Iterasi pertama berlangsung 14 detik, karena objek yang di-cache benar-benar ada di memori: 0,00 pengguna 13,78 sistem 0: 13,78 berlalu CPU 99%.
Iterasi kedua membutuhkan waktu 5 detik, meskipun tidak ada objek lagi: 0.00user 5.59sistem 0: 05.60menghapus 99% CPU.
Iterasi ketiga membutuhkan waktu 5 detik: 0,00 pengguna 5,48 sistem 0: 05,48 menghapus CPU 99%
Iterasi keempat membutuhkan waktu 8 detik: 0,00 pengguna 8.35sistem 0: 08.35melepaskan 99% CPU
Iterasi kelima membutuhkan waktu 8 detik: 0,00 pengguna 8,34sistem 0: 08,35melepaskan 99% CPU

Menjadi jelas bahwa algoritma bypass shrinker yang digunakan oleh core vanilla tidak optimal, dan kita perlu mengubahnya menjadi lebih baik dalam hal skalabilitas.

Algoritma pintas penyusutan baru

Dari algoritma baru saya ingin mencapai yang berikut:

  1. membebaskannya dari kelemahan yang lama dan
  2. Jangan tambahkan kunci baru. Panggil do_shrink_slab () hanya ketika masuk akal (yaitu, daftar tertaut terkait dari array s_dentry_lru atau dari array s_inode_lru tidak kosong), tetapi jangan langsung mengakses memori daftar yang ditautkan.

Sudah jelas bahwa ini hanya dapat disediakan oleh struktur data baru di atas heterogen heterogen (ada penyusutan tidak hanya superblok, tetapi juga objek data lain yang tidak dijelaskan dalam artikel ini. Pembaca dapat membiasakan diri dengan mereka dengan mencari kata kunci prealloc_shrinker () dalam kode kernel). Struktur data baru harus memungkinkan pengkodean dua negara: "masuk akal untuk memanggil do_shrink_slab ()" dan "tidak masuk akal untuk memanggil do_shrink_slab ()".

Struktur data tipe IDA ditolak karena mereka menggunakan kunci di dalam diri mereka. Struktur data dari bidang bit sepenuhnya cocok untuk peran ini: memungkinkan modifikasi atom dari masing-masing bit, dan dalam kombinasi dengan hambatan memori memungkinkan Anda untuk membangun algoritma yang efisien tanpa menggunakan kunci.

Setiap shrinker mendapatkan id uniknya sendiri (shrinker :: id), dan setiap memcg mendapat bitmap yang mampu berisi id terbesar dari yang saat ini terdaftar. Ketika elemen pertama ditambahkan ke daftar s_dentry_lru-> node-> memcg_lrus-> lru [memcg_id], bitmap memcg yang bersangkutan diatur ke 1 bit dengan nomor shrinker-> id. Hal yang sama dengan s_inode_id.

Sekarang loop di shrink_slab () dapat dioptimalkan untuk mem-bypass hanya shrinker yang diperlukan:

 unsigned long shrink_slab() { โ€ฆ for_each_set_bit(i, map, shrinker_nr_max) { โ€ฆ shrinker = idr_find(&shrinker_idr, i); โ€ฆ do_shrink_slab(&sc, shrinker, priority); โ€ฆ } } 

(Pembersihan bit juga diterapkan ketika shrinker memasuki kondisi "tidak masuk akal untuk memanggil do_shrink_slab (). Lihat komitmen Github untuk detail lebih lanjut.

Jika Anda mengulangi tes reset cache, kemudian menggunakan algoritma baru itu menunjukkan hasil yang jauh lebih baik:
 $time echo 3 > /proc/sys/vm/drop_caches 

Iterasi pertama: 0,00 pengguna 1.10sistem 0: 01.10 mengeluarkan 99% CPU
Iterasi kedua: 0,00 pengguna 0,00 sistem 0: 00,01 menggunakan CPU 64%
Iterasi ketiga: 0,00 pengguna 0,01 sistem 0: 00,01 mengeluarkan CPU 82%
Iterasi keempat: 0,00 pengguna 0,00 sistem 0: 00,01 menggunakan CPU 64%
Iterasi kelima: 0,00 pengguna 0,01 sistem 0: 00,01 mengeluarkan CPU 82%
Durasi iterasi kedua hingga kelima adalah 0,01 detik, 548 kali lebih cepat dari sebelumnya.

Karena tindakan serupa untuk mengatur ulang cache terjadi dengan setiap kekurangan memori pada mesin, optimisasi ini secara signifikan meningkatkan operasi mesin dengan sejumlah besar wadah dan grup kontrol memori. Seperangkat tambalan (17 buah) telah diterima ke dalam inti vanila, dan Anda dapat menemukannya di sana mulai dari versi 4.19.

Dalam proses meninjau tambalan, seorang karyawan Google muncul, dan ternyata mereka memiliki masalah yang sama. Oleh karena itu, tambalan diuji lebih lanjut pada jenis beban yang berbeda.
Akibatnya, patchset diadopsi dari iterasi ke-9; dan masuknya ke dalam inti vanila memakan waktu sekitar 4 bulan. Juga hari ini, patchset termasuk dalam kernel Virtuozzo 7 kita sendiri, dimulai dengan versi vz7.71.9

Source: https://habr.com/ru/post/id435694/


All Articles