Skalirao sam PHP dok se nije slomio — naslovna

Pročitao sam izvorni kod llama.cpp.

Šezdeset hiljada linija C++ koda koje su same omogućile lokalni LLM inference na laptopu. Ovo nisu „best practice iz udžbenika” — to je kod gde je svaka linija odgovorna za to da množenje matrica ostane unutar L2 keša i van budžeta propusnog opsega RAM-a.

Pišem PHP. Jezik gde je svaka vrednost umotana u zval, svaki objekat nosi 30+ bajtova hedera, a svaki foreach alocira hash iterator. Poređenje je nepravedno po definiciji. Ali bilo mi je radoznalo: koji od trikova llama.cpp-a uopšte preživi presađivanje? I šta će se desiti kada gurnem dataset do milijardu zapisa?

Napravio sam benchmark suite. Šest optimizacija iz llama.cpp-a, prevedenih u PHP 8.4 sa JIT-om. Realni brojevi, statistička metodologija, p99 latencije. Onda sam skalirao input od 1 miliona do 1 milijardu zapisa, da vidim gde trikovi prestaju da budu „prijatan dodatak” i postanu jedini put kojim kod uopšte može da završi.

Polovina mojih hipoteza je bila pogrešna. To je prava priča.


TL;DR

PaternNa 10M zapisaNa 100M+Verdikt
B01: mmap tabelapo pozivu 7× sporijeučitavanje 226× brže, 0 PHP heapWin na nivou procesa, ne poziva
B02: SplFixedArray vs arraysporije, ušteda 1.68× memorijeoba rade do 1B; razlika 9 GBMemorija — da, brzina — nikada
B03: Object pool u hot loop-u4.43× bržeskalira linearnoKoristi u long-running worker-ima
B04: Lookup table vs matchlookup 5.8× brži, match=switchskalira linearnoData-driven dispatch → lookup
B05: Generator vs pun array1.24× brže, memorija O(1)naivni OOM, generator završiAlat za preživljavanje
B06: Kolonski vs row layout8.66× brže single-col scannaivni OOM na 100M, kolona 959msAlat za preživljavanje

Polovina paterna na skali prelazi iz „optimizacija” u „jedini put kojim kod može završiti”. Polovina ne. A jedan patern (SplFixedArray) ispao je suprotno od onoga što se o njemu pisalo poslednjih deset godina.

Idemo redom.


B01: mmap čita gigabajte brzo, ali NE po pozivu

Hipoteza: memory-mapping velikih read-only tabela je brži od učitavanja preko json_decode. Llama.cpp paralela — modeli se učitavaju kroz ggml_mmap (vidi src/llama-mmap.cpp), ne kroz fread u malloc-ovan bafer.

PHP prevod: otvori libc.dylib preko FFI, pozovi mmap(), uzmi pointer, FFI::cast('uint32_t*', $ptr) za tipiziran pristup:

$ffi = FFI::cdef("
    void *mmap(void *addr, size_t length, int prot, int flags, int fd, long offset);
    int open(const char *pathname, int flags);
", "libc.dylib");

$fd  = $ffi->open('data/lookup.bin', 0);
$ptr = $ffi->mmap(null, $size, 1, 2, $fd, 0);
$table = FFI::cast('uint32_t*', $ptr);

// Pristup: $table[$id * 2 + 1] vraća vrednost za ključ $id

Rezultat na 10 miliona zapisa:

  • Vreme učitavanja: JSON 454 ms vs mmap 1.1 ms → mmap je 226× brži pri učitavanju
  • PHP heap nakon učitavanja: JSON 256 MB vs mmap 0 bajtova
  • p99 po jednom lookup-u: JSON 708 ns vs mmap 5.4 µs → mmap je 7× SPORIJI po pozivu

Stop. mmap gubi 7× po pozivu. JIT optimizuje $arr[$id] toliko dobro da FFI dereference sa cast overhead-om ne preživi tight read loop.

Na 1 milijardi zapisa, mmap učita 16 GB binara za 228 milisekundi uz nula PHP heap. JSON put tu ni ne postoji — fixture bi bila 100+ GB JSON teksta, fizički nerealno generisati.

B01 scaling chart

Verdikt: mmap nije „brži po pozivu”. On je druga kategorija optimizacije. Daje ti vreme učitavanja, ravan PHP heap i deljenje tabele između N PHP-FPM workera kroz kernel page cache. Unutar jednog procesa u tight read loop-u — gubi od JIT-a. Između procesa — pobeđuje za redove veličine: cross-process cold start drugog workera je 2641× brži, jer su stranice već u kernel page cache-u.

Koristi mmap kada flota workera deli debelu read-only tabelu. Ne koristi ga za tight read loop-ove unutar jednog procesa.


B02: SplFixedArray štedi memoriju, ali nikad brzinu

Hipoteza: na gustim numeričkim podacima, SplFixedArray treba da bude i brži (bez hash overhead-a) i memorijski efikasniji. Llama.cpp paralela — ggml_tensor radi sa upakovanim arenama, ne sa nizovima pointer-a na boxed objekte.

Rezultat na 10 miliona integer-a:

  • Memorija: array 256 MB vs SFA 152 MB → SFA štedi 1.68×
  • Iterate: array 12.2 ms vs SFA 93.8 ms → SFA je 7.7× SPORIJI
  • Populate: array 56.5 ms vs SFA 108.8 ms → 1.9× sporiji
  • Random reads (1M): array 23.9 ms vs SFA 98.5 ms → 4× sporiji

Očekivao sam OOM crossover, pa sam gurao sweep do milijardu integer-a nadajući se da će obični array dostići RAM plafon. Nije. Na 1B elemenata: array 24 GB peak vs SFA 14.9 GB. SFA je gubio po brzini na svakom tier-u.

B02 scaling chart

Verdikt: SplFixedArray na modernom PHP-u je samo memorija, nikada brzina. Folklor „koristi SplFixedArray za velike numeričke podatke jer je brži” — to je savet iz 2014. JIT u PHP 8.4 optimizuje upakovane integer-keyed array-e toliko agresivno da specijalizovana struktura gubi od opšte. Posegni za SFA kada si memorijski ograničen unutar long-running worker-a. Ne očekuj ubrzanje.

Ovo je najkontraintuitivniji nalaz u članku. Nisam ni sam verovao, pa sam ponovo pustio ceo sweep dva puta. Brojevi se drže.


B03: Object pool — jedina klasična optimizacija koja se još isplati

Hipoteza: u hot loop-u, ponovno korišćenje malog pula prealociranih objekata je brže od new na svakoj iteraciji. Llama.cpp paralela — tensor allocator nikad ne zove malloc unutar inner loop-a. Radi protiv prealocirane arene kroz ggml_new_tensor_impl.

Prevod: pul od 5 instanci Point3D-a, ponovo korišćen kroz direktno postavljanje properties:

final class Point3D {
    public function __construct(
        public float $x = 0.0,
        public float $y = 0.0,
        public float $z = 0.0,
    ) {}
}

$pool = array_map(fn() => new Point3D(), range(0, 4));
$idx  = 0;

for ($i = 0; $i < 5_000_000; $i++) {
    $p = $pool[$idx++ % 5];
    $p->x = $x; $p->y = $y; $p->z = $z;
    // ... rad sa $p
}

Rezultat na 5 miliona alokacija: naivni 813 ms vs pool 179 ms → 4.43× brže.

GC ciklusa: nula u oba slučaja. Point3D nije ciklični, PHP GC se ne aktivira. Sva ušteda dolazi iz allocator path-a: new u Zend Engine-u je lagan ali ne-nulti code path (zend_object_newemalloc → property init × N). Pet miliona puta — to se zbraja.

Verdikt: radi kao što se očekuje. U CLI skriptama win je realan ali ne kritičan. U long-running worker-ima (queue, websocket, daemon), tail latency od pritiska allocator-a se nakuplja vremenom i postaje glavobolja — tu se pooling isplati.


B04: Lookup table pobeđuje match i switch (a ova dva su jednaka)

Hipoteza: za dispatch logiku sa 16+ slučajeva u hot loop-u, array lookup pobeđuje match i switch. Llama.cpp paralela — token dispatch u llama_token_to_piece koristi tabele, ne switch-eve.

Prevod: klasifikator sa 32 slučaja implementiran na tri načina — switch, match i preunbild $lookup = [0 => 'A', 1 => 'B', ...].

Rezultat na 10 miliona dispatch-eva:

  • switch: 358 ms (27.9M ops/sec)
  • match: 365 ms (27.4M ops/sec)
  • lookup: 61.7 ms (162M ops/sec) — 5.8× brže

match i switch su izjednačeni. Oba se kompajliraju u istu jump tabelu za integer slučajeve. PHP 8.4 JIT poliše obe forme do istog rezultata. Ako si prepravio switch u match zbog „modernizacije” — dobio si čitljivost, ne brzinu.

Gde lookup win nestaje: ako dispatch proizvede string za downstream === poređenja, dobitak pojedu string poređenja niz pipeline.

Verdikt: match-shaped problemi (zatvoren compile-time skup, traži se exhaustiveness) ostaju u match. Data-driven dispatch (tabela učitana iz konfiga, generisana u runtime-u) ide u lookup. Debata „match vs switch za perf” je zatvorena — ekvivalentni su.


B05: Generator — glavni alat za preživljavanje na velikim stream-ovima

Hipoteza: generator smanjuje peak memoriju sa O(N) na O(1) uz sitnu kaznu po throughput-u. Llama.cpp paralela — tokeni se stream-uju kroz callback umesto da se akumuliraju u baferu (llama_decodellama_get_logits_ith).

PHP prevod: zameni function process(): array sa function process(): Generator:

function records(): Generator {
    foreach (read_csv('data.csv') as $row) {
        yield ['id' => $row[0], 'value' => $row[1]];
    }
}

Rezultat na 5 miliona zapisa:

  • Wall time: naivni 525 ms vs gen 449 ms → gen 1.24× brži
  • Peak memorija: naivni 1.88 GB vs gen 0 bajtova PHP heap-a

Generator nije samo manje-memorijski — on je i brži po wall time-u, jer array nikada ne mora biti potpuno materijalizovan pre nego što obrada počne.

Sad skala. Na 100 miliona zapisa, naivni — OOM, kernel ubija proces sa SIGKILL nakon 28.6 sekundi. Generator završava istih 100M za 10.4 sekunde uz nula PHP heap. Na 500M, generator još uvek radi (45.7 sekundi). Naivni i ne pokušava.

B05 scaling chart

Da moram da izvučem jednu rečenicu iz cele ove statije i stavim je na baner, bila bi ova:

Na 100.000 zapisa, generator je 1.24× nice-to-have. Na 100 miliona, on je jedini put kojim kod može završiti.

Verdikt: podrazumevani izbor za bilo koji single-pass stream koji ne moraš ponovo da posetiš. Materijalizuj array samo kada ti treba random access, više prolaza ili count() pre obrade.


B06: Kolonski layout — nije cache locality, nego beg od boxing-a

Hipoteza: na analitičkim single-column skenovima, kolonski layout je brži od row-orientisanog zbog cache locality. Llama.cpp paralela — tenzori se čuvaju per-channel (SoA), ne per-element (AoS).

PHP prevod: umesto SplFixedArray od stdClass sa 5 polja — 5 paralelnih SplFixedArray instanci, po jedna za svako polje:

// Row-orientisani (naivni)
$rows = new SplFixedArray($n);
for ($i = 0; $i < $n; $i++) {
    $obj = new stdClass();
    $obj->f1 = ...; $obj->f2 = ...; /* ... */ $obj->f5 = ...;
    $rows[$i] = $obj;
}
$sum = 0;
foreach ($rows as $r) $sum += $r->f3;

// Kolonski (optimizovan)
$f3 = new SplFixedArray($n); // i tako za svako polje
for ($i = 0; $i < $n; $i++) $f3[$i] = ...;
$sum = 0;
for ($i = 0; $i < $n; $i++) $sum += $f3[$i];

Rezultat na 5 miliona zapisa: kolonski je 8.66× brži na single-column skenu. Na full-row skenu (sum f1..f5) — 1.92× brži.

I tu postaje zanimljivo. Očekivao sam stepenice na ns/record grafu — gde working set prestaje da staje u L1, pa u L2, pa u L3 keš. Nisam ih video. Krive su ravne kroz ceo opseg 100K → 100M: kolona se drži na ~9.5–11.5 ns/record. Row na ~80–93 ns/record. Bez stepenica.

B06 scaling chart

Ovo je jači uvid od „evo stepenica”. Cache efekti unutar oba layout-a ih ne razlikuju. Razlikuje ih sam layout. Row-orientisani troši ~30+ bajtova po stdClass-u (zval header + property table + GC info) za 8 bajtova stvarnog payload-a. Na 100M zapisa to je 28 GB samo na boxing. Kolonski na istih 100M = 7.45 GB, jer je svaka kolona upakovan SplFixedArray bez boxing-a.

Na 100M zapisa, row — OOM, 28+ GB stdClass objekata ne staje. Kolonski završava sken za 959 milisekundi uz 7.45 GB.

Verdikt: kolonski layout nije cache optimizacija (kako sam pretpostavio). To je beg od overhead-a PHP objekata na skali. Na bilo kom analitičkom radnom opterećenju nad velikim setovima podataka — kolonski. Row ostaje primeren kada se DTO prosleđuju između slojeva ili kada je working set mali.


Šta se dešava na skali

Mikro-benchmarci na 1–10 miliona elemenata daju jednu sliku. Skaliranje na milijarde — drugačiju.

Tri od šest paterna na velikim podacima prelaze iz „optimizacije” u „nužnost”:

  • B05 generator — na 100M, naivni — OOM. Generator završava.
  • B06 kolonski layout — na 100M, row — OOM. Kolonski završava sken za 959 ms.
  • B01 mmap — na 1B, JSON fixture fizički ne postoji (100+ GB). mmap učita 16 GB binara za 228 ms.

Dva paterna ostaju „samo optimizacije” bez obzira na skalu:

  • B03 object pool: ~4× na bilo kojoj veličini.
  • B04 lookup table: ~5× na bilo kojoj veličini.

Jedan patern je ispao uzak — štedi memoriju, ali nikad brzinu:

  • B02 SplFixedArray: 38% manje memorije, uvek sporiji po brzini. Oba puta rade sve do 1B.

Ovo je verovatno najvažnije reframovanje u članku. Kada neko kaže „X je brži od Y”, to je tvrdnja o specifičnoj veličini podataka. Na malim podacima, polovina tvrdnji se lomi. Na velikim podacima, polovina se pretvara u „X radi, Y ne postoji”.

I još jedna stvar vredna posebne linije: JIT u PHP 8.4 nastavlja da jede optimizacije svakim release-om. Između run-ova na PHP 8.3.31 i 8.4.21, B03 je ubrzao sa 2.78× na 4.43×, B04 sa 3.75× na 5.81×. Nije bug — JIT se prosto nastavlja unapređivati. Za godinu dana, ovi brojevi će se opet pomeriti.


Tri pravila PHP performansi u 2026.

Iz ovih šest eksperimenata izronio je radni framework.

1. Veruj JIT-u.

Ne pokušavaj da ga nadmudriš na nivou sintakse. match vs switch — JIT kompajlira obe forme u istu jump tabelu. SplFixedArray vs upakovan array — JIT optimizuje obični array toliko agresivno da specijalizovana struktura gubi po brzini. FFI dereference vs $arr[$id] — JIT-kompajliran array access pobeđuje FFI cast-ove unutar hot loop-a.

Ako je tvoja optimizacija o tome „koji jezički konstrukt izabrati” — JIT je već napravio taj izbor za tebe.

2. Optimizuj ono što JIT ne vidi.

  • Cache locality (B06: kolonski layout) — JIT ne upravlja memory layout-om. To je tvoja arhitektura.
  • Allocation pressure (B03: object pool) — JIT ne eliminiše alokacije, samo ih ubrzava.
  • I/O batching (batched INSERT od 1000 redova vs single-row) — JIT ne optimizuje round trip-ove ka Postgres-u.
  • Cross-process resource sharing (B01: mmap + page cache) — JIT radi po procesu.
  • Streaming vs materializacija (B05: generator) — JIT ti neće ukloniti 30 GB peak memorije.

3. Na dovoljno velikoj skali, optimizacije prestaju da budu optimizacije.

Postaju prag preživljavanja. Generator na 100K zapisa je 1.24× brži. Na 100M, on je jedini kod koji završava. Kolonski layout na 5M je 8.66× brži. Na 100M, on je jedini kod koji ne pojede 28 GB na overhead-u stdClass-a. mmap na 10M je sporiji po pozivu. Na 1B, on je jedini način da učitaš tabelu unutar sekunde.

To je strukturalno razmišljanje, ne sintaksičko. I to je ono što llama.cpp pretvara iz „teško optimizovane C++ biblioteke” u učeći artefakt za PHP developer-a. Ne „evo trikovi, ukradi ih”. Nego „evo granice jezika koje vidiš samo kad u njih udariš”.


Zaključak

Sav benchmark kod i reproducibilan Docker setup žive na GitHub-u: vbcherepanov/php-llamacpp-benchmarks. Pun sweep traje ~15 minuta (make all), uključujući case study koji uveze 100K redova u realan PostgreSQL.

Napomena o repozitorijumu: direktorijum data/ je gitignore-ovan — fixture (do 16 GB binarnih lookup fajlova na 1B tier-u) se generišu lokalno kroz make fixtures. Nemoj pokušavati da kloniraš sa njima.

Ako pronađeš bug u metodologiji ili hoćeš da dodaš tier — pošalji PR. Bavim se ovakvim stvarima kroz Braincore — Go-based meta-agent sa cost-aware routing-om i memorijskim slojem za AI coding agente. Ako su ovi benchmarci bili korisni i hoćeš da podržiš još ovakvih stvari, tu je Ko-fi.