Go dilinde, derleyici yapı alanlarını bellek içerisinde kesinlikle tanım sırasına göre düzenler. Donanım erişimi için uygun bellek hizalamasını sağlamak adına, Go daha küçük bir türün ardından daha büyük bir tür geldiğinde alanlar arasında yastık baytları ekler. Alanları daha büyük türlerin (örneğin, int64, float64, unsafe.Pointer) daha küçük türlerden (örneğin, int32, int16, bool) önce gelecek şekilde yeniden düzenleyerek, geliştiriciler gereksiz iç yastığı ortadan kaldırır. Bu optimizasyon, birçok pratik durumda bir yapının ayak izini %30-50 oranında azaltabilir, doğrudan bellek yükünü azaltır ve CPU önbellek yerelliğini iyileştirir.
// Alt optimal düzen: 64-bit sistemlerde 24 bayt type MetricBad struct { Active bool // 1 bayt + 7 bayt yastık Count int64 // 8 bayt Offset int32 // 4 bayt + 4 bayt yastık } // Optimal düzen: 64-bit sistemlerde 16 bayt type MetricGood struct { Count int64 // 8 bayt Offset int32 // 4 bayt Active bool // 1 bayt + 3 bayt sona gelen yastık }
Hayattan bir tarih
Yüksek frekanslı ticaret telemetri hizmetini optimize ederken, ekip sync.Pool kullanmasına rağmen, uygulamanın zirve piyasa volatilitesi sırasında 180GB RAM tükettiğini fark etti. Hizmet, bir yapı diliminde milyarlarca emir defteri güncellemesi sakladı. İlk profil oluşturma, çöp toplayıcının yığın nesnelerini taramak için zamanının %40'ını harcadığını gösteriyordu; bu da aşırı bellek tahsisi, sızıntı değil.
Sorun
Orijinal yapı tanımı, bool bayraklarını int64 zaman damgaları ve float64 fiyatları ile iç içe geçiriyordu. 64-bit mimarilerde, her bool alanı, sonraki 8 baytlık alanı hizalamak için 7 bayt yastık eklemeye zorladı ve her 24 baytlık yapı 32 bayta şişti. 6 milyar aktif nesne ile bu, sadece hizalama yastığı nedeniyle 48GB israf bellek anlamına geliyordu ve bu durum sık sık GC döngülerine ve gecikme zirvelerine yol açıyordu.
Düşünülen farklı çözümler
Bir yaklaşım, unsafe paketlerini kullanarak verilerin byte dilimlerine açık ofset hesaplamaları ile paketlenmesini içeriyordu. Bu, yoğunluğu maksimize edebilse de, bakım yükü getirdi, ARM mimarilerinde yanlış hizalanmış atomik işlemler riski doğurdu ve tür güvenliği garanti yetkilerini ihlal etti. Diğer bir öneri, tüm alanların hizalama gereksinimlerini yarıya indirmek için float32 ve int32'ye dönüştürülmesini önermekteydi, ancak bu, düzenleyici zaman damgaları ve fiyat hesaplamaları için gereken nanosecond hassasiyetinden fedakarlık yapmayı gerektiriyordu.
Seçilen çözüm, alanları sırasıyla büyükten küçüğe yeniden sıralamayı içeriyordu: int64 ve float64 alanlarını önce yerleştirerek, sonra int32 alanlarını ve en son bool ve byte alanlarını. Bu, iş mantığında herhangi bir değişiklik gerektirmedi, tür güvenliğini korudu ve yapı boyutunu 32 bayttan 16 bayta düşürdü. Sona gelen yastık, dizi hizalaması için gerekli olmaya devam etti ancak tüm iç parçalanmayı ortadan kaldırdı.
Sonuç
Dağıtımdan sonra bellek kullanımı %33 oranında düşerek 120GB'a, GC duraklama süreleri 45ms'den 12ms'ye ve CPU kullanımı %18 oranında azaldı; bu, iyileşmiş önbellek hat dizelemesi nedeniyle gerçekleşti. Değişiklik yalnızca üç satır kod değişikliği gerektirdi ancak bu, o sürüm döngüsündeki en büyük performans iyileştirmesini sağladı.
Go derleyicisi, bellek düzenini optimize etmek için yapı alanlarını otomatik olarak yeniden sıralar mı?
Hayır, Go derleyicisi alan tanım sırasını koruyarak C ile etkileşim ve hata ayıklama amaçları için öngörülebilir bellek düzenlerini kesinlikle sağlamaktadır. Belirli pragma direktifleri altında düzen optimizasyonu yapabilen C derleyicilerinin aksine, Go yapı tanımını bir sözleşme olarak ele alır. Derleyici, her alanın hizalama gereksinimini karşılamak için genellikle alanın altındaki türün boyutuna eşit olan alan ihtiyaçlarını sağlamak adına yastık baytları ekler. Geliştiriciler, yastığın minimum olmasını sağlamak için alanları büyükten küçüğe manuel olarak sıralamak zorundadır veya verimsiz düzenleri algılamak için fieldalignment gibi dış araçlar kullanabilirler.
Bir yapının toplam boyutunun en büyük alan hizalaması ile çarpan olacak şekilde yastıklanması neden gereklidir?
Bu kısıtlama, dizi tahsisini desteklemek içindir. Bir yapı dilimi veya dizisi oluşturduğunuzda, her öğeninin uygun şekilde hizalanmış bir adreste başlaması gerekir. Yapının boyutu, en büyük alanın hizalama sınırına yuvarlanmazsa, dizideki ikinci öğe yanlış hizalanmış bir ofsetten başlayacak ve ARM veya SPARC gibi RISC mimarilerinde donanım seviyesinde hizalama hatalarına yol açacak ve x86 üzerinde performans cezaları getirecektir. Go ayrıca atomik işlemler için uygun hizalama gerektirir; bir int64 alanı, sync/atomic işlevlerinin doğru bir şekilde çalışabilmesi için 32-bit sistemlerde bile 8 bayt hizalı olmalıdır, bu da çalışma zamanı paniklerine neden olmamalıdır.
Alana hizalaması, çok iş parçacıklı uygulamalarda yanlış paylaşım ile nasıl etkileşir?
Optimal boyut sıralamasına rağmen, adaylar genellikle önbellek hat dizilimini gözden kaçırır. İki goroutine farklı CPU çekirdeklerinde aynı 64 baytlık önbellek hattındaki bitişik alanları sıkça değiştirdiğinde, bellek erişimini serileştirerek performansı düşüren önbellek tutarlılığı trafiğini tetikler. Klasik bir tuzak, kilit alanını sıkça değiştirilen veri alanlarının yanında yerleştirmektir; kilit edinimi, verileri içeren önbellek hattını geçersiz kılar. Çözüm, yapıların tüm önbellek hatlarını kaplamasını sağlamak için açık yastık eklemeyi (tipik olarak _[56]byte) kullanmak veya tahsislere önbellek hat sınırlarına hizalamak için runtime.AlignUp kullanmaktır, böylece bağımsız goroutine'ler arasında yanlış paylaşımı önler.