İçeriğe geç
Ekosistem ve Araçlar

Benchmark Sonuçları

Tulpar'ın CPU ve HTTP iş yüklerinde C / Go / Node.js / FastAPI karşısında nasıl performans verdiği.

13 dk okuma

Tulpar’ın sloganı “Python kadar kolay, C kadar hızlı”. CPU’da fib ve strcat’te C’yi — ona -O3 -march=native -flto verildiğinde bile — geçiyor, sieve ve intloop’ta başa baş; saf kayan nokta aritmetiğinde C’den ayırt edilemiyor, ama kayan nokta dizilerinde 5–27× geride (ölçüldü, aşağıda); HTTP’de çok-çekirdekli listen_pool sunucusu Go’nun net/http’sini geçiyor ve FastAPI’yi çok geride bırakıyor — hepsi gönderilecek bir runtime olmadan, tek bağımsız binary’den.

Dokuz dil, sekiz mikro-kıyas, tek makine. Düşük olan hızlı; her sütunun en hızlı girdisi kalın. Her dil iş yükü boyutunu BENCH_N ortam değişkeninden okur, böylece hiçbir derleyici döngüyü kapalı forma katlayıp onu hiç çalıştırmadan “kazanamaz”. 7 koşumun en iyisi, 2026-09-08, tek bir Linux kutusunda (Tulpar LLVM 22 ile derlendi), commit 3edb4f4.

Dilfib(32)sieve(5M)strcat(2M)arrayiter(5M)intloop(50M)
Tulpar AOT0,67,713,21,2134,5
C (gcc -O2)1,67,637,62,2134,4
C++ (g++ -O2)1,98,014,72,7134,9
Rust (-O3)3,88,118,71,5144,0
Go6,78,524,34,3134,5
Java12,419,732,918,2144,1
C# (.NET)20,320,931,218,3149,3
Node.js24,628,296,418,5712,2
Python140,8448,1200,0410,23127,3

Aynı makinede boş program taban çizgisi — süreç başlatma maliyeti, ve yukarıdaki her sayının içinde: C 0,17 · Tulpar 0,23 · C++ 0,44 · Python 5,6 · C# 8,2 · Node 10,7 ms.

Tulpar AOT fib’de C’yi geçiyor, sieve ve intloop’ta başa baş. Yukarıdaki strcat ve arrayiter satırları, C’ye eşit bayraklar ve aynı tamsayı biçimlendirme yordamı verilince kazanç değil — bu tablodan bir şey alıntılamadan önce aşağıdaki iki bölümü oku.

Bir satırı alıntılamadan önce dikkatle okunması gereken üç şey:

  • intloop ve sieve fiilen dörtlü beraberlik. İkisinde de ilk dört dil birbirinin 0,5 ms içinde; bu aralıkta sıra numarası koşumdan koşuma değişiyor. Anlamlı olan bant, bandın içindeki sıra değil.
  • strcatte C sondan üçüncü (37,6 ms) — elle yazılmış realloc + snprintf döngüsü, C++‘ın std::stringinden 2,5 kat yavaş. Kap seçimi dil seçimini yeniyor; “C her zaman en hızlıdır” bir yasa değil.
  • Java ve C# bu sayılarda açılış maliyeti taşıyor. Tek başına C#‘ın boş programı 8,2 ms. Kıyas, kullanıcının hissettiği şey olduğu için duvar saatini ölçüyor; maliyet çıkarılmıyor, gösteriliyor — ama kısa iş yüklerinde o iki satırın çoğu bu.

”C’den hızlı” iddiası C’nin EN İYİ bayraklarına dayanıyor mu?

Section titled “”C’den hızlı” iddiası C’nin EN İYİ bayraklarına dayanıyor mu?”

Tablo C’yi gcc -O2 ile derliyor — Tulpar’ın kendisinin hedeflediği jenerik tabanla aynı (LLVM hedef CPU’su varsayılan "generic"; -march=native karşılığı opt-in ve bu sayılarda kullanılmıyor). Adil bir varsayılan, ama “C’den hızlı” sıra dışı bir iddia; o yüzden C’ye en iyi bayrakları verip yeniden ölçüldü. 12 dönüşümlü tekrar, ortanca ± MAD, AMD Ryzen 7 9800X3D (Zen 5, 5,27 GHz):

KıyasC -O2C -O3 -march=native -fltoTulpar (generic)Sonuç
fib(32)2,492,08 ± 0,040,77 ± 0,02Tulpar 2,7× — duruyor
strcat(2M)37,9536,98 ± 0,3713,27 ± 0,21Tulpar 2,8× — duruyor
sieve(5M)7,867,75 ± 0,147,77 ± 0,10berabere (MAD içinde)
arrayiter(5M)2,501,852,03C native ile geri alıyor

Yani dürüst ifade tek bir “C’yi geçtik”ten dar: fib ve strcat kazançları sağlam ve C’nin en iyi bayraklarına karşı da duruyor; sieve/intloop berabere; arrayiter yalnız eşit jenerik bayrakta Tulpar’ın. Tek başına -O2 → -O3 neredeyse hiçbir şey değiştirmiyor (fib 2491 → 2290 µs, strcat 37951 → 38133); arrayiter’i çeviren şey -march=native.

Sayıların tek başına taşımadığı iki açıklama:

  • strcat yalnız derleyicileri değil, farklı araçları kıyaslıyor. C tarafı naif değil — geometrik büyümeli tampon (cap *= 2) — ama her sayıyı genel amaçlı snprintf ile biçimliyor; Tulpar tam bu yol için optimize edilmiş özel bir tamsayı→dizgi yordamı kullanıyor. Elle itoa yazan bir C programcısı farkın çoğunu kapatır.

  • fib kazancı mikro-mimari değil, ALGORİTMİK (2026-09-11’de ölçülüp adlandırıldı). Süreç açılışı aynı ikiliden çıkarılıp büyüme üssü ölçüldü (naif ağaç φ = 1,618, n’deki her +1 için):

    üs
    clang -O31,604
    gcc -O21,607
    Tulpar, zincir kapalı1,612
    Tulpar1,488

    gcc’nin üssü de naif — gcc’nin fib gövdesi 264 komut (clang 20), yani agresif açma, ama sabit çarpan kazancı. Tulpar’ın klon zinciri fibi bir kez satır içine alıyor; fib(n) = fib(n-2) + 2·fib(n-3) + fib(n-4) oluyor ve ortak alt ifade eliminasyonu yinelenen fib(n-3)ü birleştiriyor. Geriye 3 çağrı kalıyor (makine kodunda doğrulandı), bağıntı T(n) = T(n-2)+T(n-3)+T(n-4) oluyor ve kökü 1,4656 — ölçülen 1,488.

    ⚠ Bunun sonucu: fib oranı sabit değil, n ile büyüyor. Açılış çıkarılmış, gcc -O2ye karşı: n=34’te 12,3× · n=36’da 12,0× · n=38’de 14,5× · n=40’ta 17,3×. Yukarıdaki tablodaki fib(32) satırı ıraksayan bir eğrinin üstünde bir nokta — daha küçük n’de fark kapanır, daha büyükte açılır. Dürüst cümle: aynı karmaşıklık sınıfı, daha küçük taban — her iki taraf da üstel; fark tabanda (1,488 vs 1,607), sınıfta değil. “Farklı sınıf” polinom-vs-üstel demek olurdu; burada olan o değil. Oran (1,607/1,488)ⁿ ile büyüyor, yani sınırsız — ama büyüme aynı ailenin içinde.

Kayan nokta: tek cevap yok, iki ayrı cevap var

Section titled “Kayan nokta: tek cevap yok, iki ayrı cevap var”

Yukarıdaki beş çekirdeğin hepsi tam sayı ya da dizgi. “Peki ya float?” sorusu 2026-09-11’de üç yeni çekirdekle ölçüldü — ve tek sayıyla cevaplanamayacağı ortaya çıktı, çünkü iki ayrı maliyet var:

  • değer temsili — her işlemde kutu aç/kapa
  • depolama temsili — dizide eleman başına kaç bayt

Çekirdekler bunları ayıracak şekilde seçildi:

ÇekirdekNe ölçerDizi kullanır mıC (gcc -O2)TulparOran
mandelbrot(2000)saf kayan nokta aritmetiğihayır158,6158,41,00×
nbody(3M)aritmetik + küçük dizi + sqrtevet, küçük114,81326,111,6×
matmul(640)kayan nokta dizisiyalnız o31,0824,526,6×

Saf kayan nokta aritmetiğinde Tulpar C’den ayırt edilemiyor — 158,4 ms’ye karşı 158,6 ms. Aynı koşuda Python 15 721 ms. Yani float değerleri kutulanmıyor; aritmetik yerel değişkenlerde ham double olarak yürüyor.

Dizilerde durum tersine dönüyor. Sebebi ölçüldü — aynı döngü, yalnız eleman tipi değişiyor (20M eleman):

sürebayt/eleman
int[], C (long long*)13,9 ms8,1
int[], Tulpar20,6 ms4,1
float[], C (double*)16,0 ms8,1
float[], Tulpar87,0 ms16,1

Tulpar’ın tam sayı dizisi C’ninkinden dar (eleman başına 4,1 bayt — 32 bite sığan değerler otomatik daraltılıyor). Aynı motorda kayan nokta dizisi iki katı yer tutuyor, çünkü kutulanmamış depolama şu an yalnız tam sayılar için var. matmul’un 26,6בi düz taramanın 4,2בinden büyük: kutulu elemanlar “kanıtlı erişim” hızlı yoluna giremiyor ve 16 baytlık elemanlar derleyicinin iç döngüyü vektörleştirmesini de engelliyor.

⚠ Bu tablo, tek çekirdekle ölçmenin neden yanıltıcı olduğunun da kaydı: yalnız mandelbrot’a bakan “float bedava”, yalnız matmul’a bakan “float 26× yavaş” derdi. İkisi de yanlış, çünkü ikisi de gerçek.

Tek makinede sekiz mikro-çekirdek “en hızlı dil” iddiasını taşıyamaz. Kapsam dışı: elle yazılmış SIMD, tahsis baskısı ve hash-map/JSON yükleri — arena modelinin bedeli ancak orada görünür — işaretçi takibi, sıralama, çok iş parçacıklı ölçekleme, RSS, ve sürekli yük altında p99. Savunulabilir okuma: Tulpar tam sayı, dizgi ve skaler kayan nokta çekirdeklerinde C sınıfında, Node/Python/Java/C#‘ın açık ara önünde, fib ile strcat’te özellikle C’nin de önünde — ve kayan nokta dizilerinde ölçülmüş biçimde geride (yukarıdaki FP bölümü).

Makine: AMD Ryzen 7 9800X3D (Zen 5, 8Ç/16İP, 96 MB 3D V-Cache), Linux. Araç zincirleri: gcc 16.2.1 · rustc 1.89.0 · go 1.27.1 · Tulpar AOT (LLVM 22).

Bu sayfanın daha önceki bir sürümü Tulpar’ı “C’nin 1,37–1,9 katı sürede” diye gösteriyordu. O tablo Eylül 2026’da denetlendi ve çöpe atıldı — her biri tek başına sonucu geçersiz kılan üç kusuru vardı; en ağırı, iş yükü boyutunu çalışma zamanında yalnızca Tulpar’ın okuması, gcc -O2 ve rustc -O3’ün ise kendilerininkini sabit-katlayıp yok etmesiydi. Yerine gelen takım benchmarks/fair/ altında ve üçünü de düzeltiyor:

  • İş yükü boyutu her dilde ortamdan geliyor, yani derleme zamanında hiçbir şey katlanamıyor.
  • Aynı algoritma, aynı veri yapısı — her dil kendi deyimsel aracını kullanarak (StringBuilder / std::string / strings.Builder / Int32Array / int[]). Birine naif yolu dayatmak dili değil, o tuzağı ölçerdi.
  • Çıktılar diller arasında karşılaştırılıyor. Uyuşmazlarsa satır yayınlanmıyor, geçersiz raporlanıyor. Yukarıdaki beş satırın hepsi uyuşuyor.
  • Isıtma koşumu atılıyor, en iyi ve ortanca kaydediliyor, boş program taban çizgisi yanına yazılıyor ki bir sayının ne kadarının açılış olduğunu görebilesin.

Yeniden üretmek için: cd benchmarks/fair && python3 run.py. Eksik araç zincirleri kendi satırlarını düşürür, kalanlar normal koşar.

benchmarks/loadtest (native C yük üreteci), her sunucuyu keep-alive bağlantılar üzerinden GET / → JSON {"hello":"world"} ile döver; concurrency 1–12 arasında taranır (kutunun çekirdek sayısının altında tutulur, böylece yük üreteci sunucuyu aç bırakmaz), seviye başına 4 sn, en iyi koşu ikinci geçişle doğrulanır. Kutu: 14-vCPU WSL2. Her runtime kendi önerilen tek-process yapılandırmasında.

Bu HTTP rakamları, yukarıdaki CPU tablosundan ayrı ve daha eski bir koşumdan, farklı bir makineden geliyor — satırları kendi tablosu içinde karşılaştır, tablolar arasında değil.

Sunucuistek/snp50 latencyYapılandırma
Tulpar listen_pool~36k0.32 ms14 çekirdek, 1 process
Go net/http~30k0.38 mstüm çekirdekler (default), 1 process
Node.js http~8.7k1.06 ms1 thread (default)
FastAPI (uvicorn)~3.5k3.31 ms1 worker (default)
Tulpar listen~4–4.7k0.22 ms1 thread, seri accept döngüsü

Thread modelleri farklıdır ve yukarıda etiketlenmiştir: listen_pool ve Go’nun net/http’si kutudan çıkarken tüm çekirdekleri kullanır, Node ve tek bir uvicorn worker’ı ise default’ta tek. Tulpar’ın tek-thread listen()’i seri bir accept döngüsüdür — en düşük request-başı latency’ye sahip (0.22 ms p50) ama keep-alive bağlantıları serileştirir; bu yüzden throughput için listen_pool (veya listen_async) kullan. Dikkat: tek-thread listen() burada tek-thread Node’un gerisinde kalıyor; Tulpar’ın üstünlüğü listen_pool’un çekirdekler arasında temiz ölçeklenmesinden geliyor (36k istek/sn’de p50 0.32 ms’de kalıyor, temiz sub-millisecond tail ile).

Özellikle FastAPI’ye karşı Tulpar latency (~10× daha düşük p50) ve ayak izinde de net kazanıyor — ayrıntılı Wings vs FastAPI yazısına bak (yük altında p50 0.31 ms vs 28 ms, 6.7 MB vs 54 MB RSS, 2 MB bağımsız binary vs Python + ~50 MB bağımlılık).

İlk adil ölçümden (2026-09-02) bu yana — ki o ölçümde Tulpar beş kıyasın hiçbirinde ilk üçe girmiyordu: strcat 233,5 → 13,2 ms (17,7×), fib(32) 5,8 → 0,6 ms (9,7×), sieve 60,4 → 7,7 ms (7,8×), arrayiter 6,6 → 1,2 ms (5,5×). intloop beklendiği gibi hiç kıpırdamadı — seri bağımlılık zinciri olduğu için derleyiciyi değil, CPU’yu ölçüyor.

  • Kutulanmamış sayısal diziler. Bir dizi ya kutulu VMValue vektörü ya da ham tamsayı tamponu; eleman erişimi codegen’de satır içi GEP + load, üstüne eleman deposunu dizi başlığından ayıran TBAA ve döngü-değişmezi şekil önbelleği — işaretçi ile uzunluk yineleme başına değil, döngü başına bir kez okunuyor.
  • Döngü sürümleme. for/while gövdesi iki kez üretiliyor; hızlı sürümde sınır denetimi hiç yok, çünkü döngü girişindeki tek bir sınav onu gereksiz kanıtlıyor. Kanıt sözdizimsel değil, anlamsal.
  • 32-bit eleman deposu. C/Rust/Go elekte 4 baytlık eleman kullanıyor, biz 8 kullanıyorduk. Diziler artık 32-bit başlıyor ve sığmayan bir değer yazılınca genişliyor (asla kutulanmıyor) — dilin inti 64-bit kalıyor. Genişlik dalı eleman erişiminde değil sürüm seçiminde, yani iki genişlik de sıcak yolda dalsız.
  • Özyineleme zinciri. LLVM bir fonksiyonu kendi içine satır içi almaz (gcc alır — fibde bütün LLVM dillerini 2,4 kat geçmesinin tek sebebi buydu). Arka uç dört kopya üretip halkaya diziyor; artık her kenar iki farklı fonksiyon arası çağrı olduğu için sıradan satır içi alıcı onları açıyor.
  • Açılış maliyeti. Her ikili OpenSSL yüklüyor ve libstdc++‘ı dinamik bağlıyordu: boş program 1,15 → 0,23 ms.
  • Dizgi kurma. StringBuilder + kutulamasız sb_append + hızlı itoa; ayrıca dizgi sabitleri internleniyor.
  • Tipsiz yol. % operatörünün kutulu satır içi hızlı yolu yoktu ve her kutulu global ataması koşulsuz bir çalışma zamanı çağrısı ödüyordu; ikisi de kapatıldı. Kutulu fonksiyonlar ayrıca değer ABI’sine geçti (argüman ve dönüş yazmaçta): tipsiz fib 11,9 → 7,7 ms.

Bunların hepsi ölçümle bulundu; ölçülüp elenen denemeler de yanlarında docs/mindmap/Performance.md içinde kayıtlı.

  • call(handler_name) dlsym cache (256-slot FNV-1a hash) — request başı sembol-tablosu walk’unu eliyor.
  • Accept’te TCP_NODELAY — Nagle’ın 40ms gecikmesini kaldırır (+%13).
  • Static thread-local recv buffer — keep-alive request başı 64KB malloc/free pair’ini düşürür.
  • Per-request arena reset + per-request malloc region — uzun süreli sunucularda sızıntısız bounded memory.
  • Built-in’lerde thread-local scratch buffer — TLS olmayan static’ler listen_pool altında yarışıyordu (bir toString buffer’ı düzeltilene dek ~%1.1 hatalı 404’e yol açtı).
  • break / continue real codegen — eskiden silently no-op’tu; LLVM induction-variable update’lerinde suboptimal phi node üretmesini engeller.

Bilinçli atladığımız trade-off’lar (analiz edildi, düşük değer):

  • Object-key inline caching (req["method"] HTTP path’in ~%0.3’ü).
  • String concat coalescing (a + b + c HTTP path’in ~%0.1’i).
  • CPU: tek Linux kutusunda dokuz araç zinciri — gcc -O2 / g++ -O2 / rustc -O3 / Go / Java / .NET / Node / CPython / Tulpar AOT (LLVM 22). Isıtma koşumu atılır, 7 duvar saati koşumunun en iyisi alınır, iş yükü boyutu çalışma zamanında BENCH_N’den okunur (hiçbir derleyici sabit-katlayamaz) ve çıktılar diller arasında karşılaştırılır.
  • HTTP: native benchmarks/loadtest, tek kutu, keep-alive, concurrency çekirdek sayısı ≤ tutulur, böylece yük üreteci ve sunucu CPU için yarışmaz. Toolchain’ler: Go 1.23, Node 22, Python 3.14 + FastAPI/uvicorn. Sunucular default tek-process yapılandırmasında çalışır (çekirdek kullanımı satır başına etiketli).
  • Mutlak rakamlar kutuya özeldir; taşınabilir sinyal olarak oranları ve latency’yi baz al. CPU’yu cd benchmarks/fair && python3 run.py ile yeniden üret (sonuçları emekliye ayrılan takım eski run_benchmarks.sh’tir); buradaki HTTP sunucuları + sürücü, aynı JSON’u döndüren minimal eşdeğerlerdir.
  • Bunlar mikro-kıyaslar — tek makinede sıkı döngüler. Derleyici ve çalışma zamanı maliyetini yalıtır, dilleri aynı iş şekli üzerinde karşılaştırılabilir kılar. Üretim trafiğini modellemezler: soğuk başlangıç, büyük yükler, dağıtık istemciler, p99 kuyruk, sürekli yük altında GC baskısı.