
Compiled vs. Interpreted

Ц.Анужин
Багш


Ц.Анужин
Багш
Программчлалын анхан шатны хичээл болгон дээр яг ижил диаграм заадаг: compiled хэлүүд нь (C, Rust, Go) таны эх кодыг урьдчилан машины код руу хувиргадаг, харин interpreted хэлүүд нь (Python, Ruby, JavaScript) таны эх кодыг мөр мөрөөр нь ажиллах явцад уншиж, шууд гүйцэтгэдэг. "Compiled хэл хурдан, баригдмал. Interpreted хэл удаан, уян хатан." Гэхдээ энэ нь 30 орчим жилийн турш ихэнхдээ буруу байсаар ирсэн.
Compilation - Компайлер таны бүх эх файлыг уншиж, шинжилж, процессор шууд гүйцэтгэж чадах машины код, заримдаа завсрын формат үүсгэдэг. Энэ нь программ ажиллахаас өмнө аль эрт болдог. Эмхэтгэгдсэний дараа гарсан binary файл өөрөө "C" эсвэл "Rust" байсан гэдгээ огт мэддэггүй, зөвхөн процессорт зориулсан заавар л болчихсон байдаг. Компайлер их цаг зарцуулж бодож болно, учир нь тэр зөвхөн нэг л удаа бодох ёстой.
Interpreter - таны эх код (эсвэл түүнд ойрхон зүйл)-ыг уншиж, мөр мөрөөр нь, урьдчилан биш харин явцын дундаа тухай бүрт нь үйлдэл рүү хувиргаж, шууд гүйцэтгэдэг. Бие даасан artifact үүсгэдэг тусдаа "build" үе шат байхгүй, эх код (эсвэл түүний бага зэрэг боловсруулсан хувилбар) болон гүйцэтгэл нь ажиллах явцад хамт холбогддог.

Өнөө цагт гарч буй програм хангамжийн бараг юу нь ч цэвэр аль нэг хэлбэрээр нь биш, мөн эдгээрийн хоорондох хил хязгаар нь 90-ээд оны JVM гуравдагч сонголтыг түгээмэл болгосноос хойш чимээгүйхэн эвдэрсээр ирсэн.
"Interpreted" хэлүүд ч гэсэн compile хийдэг. Python таны эх кодыг ажиллуулах бүрд дахин мөр мөрөөр нь задалж уншдаггүй. Энэ нь таны эх кодыг шаардлагатай эхний удаа bytecode (доод түвшний зааврын формат) руу эмхэтгэж, кэшлээд хадгалдаг (__pycache__-д байдаг тэр .pyc файлууд нь яг л эмхэтгэсэн artifact), дараа нь таны бичсэн эх текстийг биш, харин тэр bytecode-ыг тайлбарладаг. Ruby ч бас ижил төстэй зүйл хийдэг. PHP ч мөн адил. Ажиллах явцад болж буй "тайлбарлал" гэдэг чинь хүн бичсэн эх кодыг тайлбарлаж байгаа хэрэг биш, харин аль хэдийн compile хийгдсэн завсрын дүрслэлийг тайлбарлаж байгаа хэрэг юм. Сурах бичигт заадаг диаграм нь 1995 оноос хойш бараг хэн ч бодит байдал дээр бүтээгээгүй зүйлийг дүрсэлдэг.
"Compiled" хэлүүд ч гэсэн доороо interpreter хэлбэртэй runtime тээж явдаг. Go-ийн binary файлууд статик эмхэтгэлийн алхам бүрэн шийдэж чадахгүй runtime шийдвэр гаргадаг scheduler болон garbage collector-ыг дотроо шингээж явдаг — аль goroutine хэзээ ажиллахыг компайлер урьдчилан мэдэж чадахгүй нөхцөл байдлаас хамааруулан ажиллах явцад динамикаар шийддэг. C, Rust хэлүүд сурах бичгийн зурагт хамгийн ойрхон, жинхэнэ хамгийн бага runtime-тай ч гэсэн, тэд ч гэсэн dynamic linking дээр тулгуурладаг — энэ нь номын сангийн код нь программ эмхэтгэгдэх үед биш, харин программ эхлэх мөчид бодит санах ойн хаяг руу шийдэгддэг явдал юм.
Хэрэв compiled/interpreted тэнхлэг цаашид гүйцэтгэлийг цэвэрхэн урьдчилан таамаглаж чадахгүй бол, юу нь тодорхойлдог вэ? Хуучин ангилалуудыг огт хамааруулахгүйгээр огтолж өнгөрдөг цөөн хэдэн шинж чанарууд бий:
Runtime урьдчилан хэр их зүйл мэддэг вэ. Статик төрөлжилт нь компайлерт санах ойн байршил, функцийн дуудлагын тухай бат шийдвэрийг урьдчилан гаргах боломж олгодог. Динамик төрөлжилт эдгээр шийдвэрийг runtime рүү түлхдэг, тэнд тэдгээр нь дуудлага бүрд төлөгддөг (удаан) эсвэл бодит байдал дээр төрөл хэзээ ч өөрчлөгддөггүйг анзаарсан хангалттай ухаалаг JIT-ээр оптимчлогдож арилдаг (хурдан, гэхдээ загварыг анзаарахад хангалттай тооны давталт хийсний дараа л — яг энэ шалтгаанаар JIT-ээр эмхэтгэсэн код анхны удаад алдартайгаар удаан, зуу дахь удаад хурдан байдаг).
Санах ой удирдах стратеги. Гараар удирдах санах ой (C), runtime зардалгүй эзэмшилд суурилсан удирдлага (Rust), garbage collection (Java, Go, Python, JavaScript) — эдгээр нь compiled эсвэл interpreted-тэй огт холбоогүй, харин санах ой хэрхэн хянагдаж, буцаан авагддагтай бүрэн холбоотой, туйлын өөр гүйцэтгэлийн шинж чанартай.
Абстракц бүрд хэр их ажил хийгддэг вэ. Runtime-д ноцтой төрлийн шалгалт, динамик dispatch, эсвэл reflection ашигладаг хэл нь тухайн үйлдэл нь compile алхмаар эсвэл interpretation алхмаар тэнд хүрсэн эсэхээс үл хамааран, үйлдэл бүрд бодит зардал төлдөг.
Эдгээрийн алинд нь ч compiled/interpreted хоёртын ангилал тохирдоггүй. Муу оптимчлогдсон ahead-of-time компайлер боловсорсон JIT-ээс удаан код гаргаж чадна. Дулаарахад хангалттай давталт авдаггүй JIT — богино хугацаанд ажилладаг script, serverless функцүүдэд түгээмэл тохиолддог асуудал — жинхэнэ давуу талаа харуулж амжихгүй, тэр тодорхой ачааллын хувьд энгийн interpreter-ээс ч удаан болж дуусдаг.
Онолыг ойлгоход хамгийн хялбар арга бол жинхэнэ тоо харах явдал. Дараах маш энгийн жишээг авч үзье — 1-ээс 100 сая хүртэлх тоог нэмэх гэсэн зүгээр л for давталт:
sum = 0
for i in 1..100,000,000:
sum = sum + i
Хэрэв та энэ кодыг Python-ий энгийн for давталтаар шууд бичвэл, ойролцоогоор 5-6 секунд зарцуулна. Яагаад ийм удаан бэ гэвэл, Python interpreter давталтын мөр бүрд sum хувьсагчийн төрлийг шалгаж, + үйлдлийн утгыг дахин дахин тодорхойлж, объект бүрийг санах ойд шинээр байрлуулж байдаг — bytecode руу эмхэтгэгдсэн ч гэсэн, тэр bytecode-ыг тайлбарлах алхам бүр дэх зардал асар их байдаг.
import numpy as np
sum(np.arange(100_000_000))
Яг ижил тооцоог NumPy-гаар хийвэл ердөө 0.1-0.2 секунд орчим зарцуулна — 30-50 дахин хурдан. Гайхмаар зүйл бол: энэ бол "илүү хурдан Python" биш. NumPy-ийн дотор бүх тооцоо C хэлээр урьдчилан эмхэтгэгдсэн, маш нягт зохион байгуулалттай санах ойн массив дээр ажилладаг. Python энд зөвхөн "жолооч" болж, жинхэнэ ажлыг бүхэлд нь C код хийдэг. Энэ бол өмнөх блог дээр дурдсан зүйлийн яг тодорхой жишээ — "Python удаан" гэдэг нь Python хэлний runtime-ийн тухай биш, харин тэр runtime-ийг хэрхэн ашиглаж байгаагийн тухай өгүүлдэг гэдгийг харуулж байна.
let sum = 0;
for (let i = 0; i < 100000000; i++) {
sum += i;
}
Энэ кодыг Node.js дээр анх удаа ажиллуулбал секундын нэг хэсэг зарцуулна — гэхдээ энд сонирхолтой зүйл болдог. V8 engine эхэндээ давталтыг энгийнээр тайлбарлаж эхэлдэг, гэвч хэдхэн мянган давталтын дараа "энэ давталт олон удаа ажиллаж байна, i болон sum хоёул үргэлж тоо байна" гэдгийг анзаардаг. Тэр даруй V8 энэ тодорхой давталтыг шууд процессорын native машины код руу эмхэтгэдэг — динамикаар, ажиллах явцын дундаа. Үр дүн нь: ойролцоогоор 0.1 секунд, буюу C хэлээр бичсэн ижил давталттай бараг ялгаагүй хурдтай.
Энэ гурван жишээ нэг л зүйлийг маш тод харуулж байна: код нь юугаар бичигдсэн бэ гэдэг биш, харин тухайн runtime тэр кодтой хэрхэн харьцдаг бэ гэдэг нь хурдыг тодорхойлдог гэдгийг. "Python удаан, JavaScript дунд зэрэг, C хурдан" гэсэн ерөнхий дүрэм бол дунджаар зөв ч, дэлгэрэнгүй нөхцөл байдал дээр бараг утгагүй болдог — учир нь ижил хэл дотор ч гэсэн, та хэрхэн код бичсэнээс шалтгаалж, гүйцэтгэл 50 дахин ялгарах боломжтой.