مقالات معمّقة حول التكنولوجيا التي تشكّل المستقبل.

متى لا يكون WebAssembly أسرع من JavaScript

ليس WebAssembly أسرع من JavaScript دائماً. اختبارات أداء حقيقية تُظهر متى تتفوق TypeScript على WASM، ولماذا يقتل عبور الحدود الأداء.

عدّاء متوقف عند نقطة تفتيش حدودية بينما يمر آخر بسرعة

الشهر الماضي قضيت يوم سبت كاملاً في إعادة كتابة محلل بيانات الصور الوصفية (image metadata parser) من Rust إلى WASM بلغة TypeScript. نسخة WASM كانت في الإنتاج لثمانية أشهر. كانت تعمل بشكل جيد، ولم يشتكِ أحد منها. لكنني كنت أحدّق في تتبعات الأداء (performance traces) الخاصة بنا، وشيء ما أزعجني: محلل WASM كان يقضي وقتاً أطول في نقل البيانات عبر حدود JS-WASM مما يقضيه في التحليل الفعلي. فأعدت كتابته. TypeScript خالص، بدون WASM. النتيجة كانت أسرع بنسبة 38% للحمولة المتوسطة لدينا. لم أكتب خوارزميات أفضل. كل ما فعلته أنني توقفت عن دفع ضريبة عبور الحدود.

تلك التجربة غيّرت شيئاً في تفكيري. كنت من أولئك المطورين الذين يفترضون أن WebAssembly هو الخيار الأسرع، دائماً. واتضح أن هذا الافتراض خاطئ في حالات أكثر مما نتصور.

خرافة «WASM أسرع دائماً» يجب أن تموت

هذا هو النموذج الذهني الذي يحمله معظم المطورين: اللغة المُترجمة (compiled) تُحوَّل إلى WASM، وWASM يعمل بسرعة قريبة من الأصلية، إذن WASM أسرع من JavaScript. كل حلقة في هذه السلسلة صحيحة تقريباً. لكن الاستنتاج لا يتبع منطقياً، لأنه يتجاهل تكلفة كل ما يحيط بالحساب نفسه: إدخال البيانات، وإخراج النتائج، وعبء تشغيل بيئتي تنفيذ جنباً إلى جنب.

محركات JavaScript متقنة بشكل مذهل. V8 وSpiderMonkey وJavaScriptCore تمثل عقوداً من أعمال التحسين، تمولها بعض أغنى شركات الكوكب. هي تنفذ التجميع الفوري التخميني (speculative JIT)، والـ inline caching، وescape analysis، وانتقالات hidden class. في كثير من أحمال العمل، خصوصاً الأنماط الكثيفة بالنصوص والكائنات والـ callbacks الشائعة في تطبيقات الويب، ينتج محرك JS الحديث شيفرة آلة قريبة بشكل مدهش مما تحصل عليه من مترجم ساكن.

WASM لا يحصل على هذه التحسينات. يُترجم مسبقاً (AOT)، فما تشحنه هو ما يعمل. هذه ميزة من ناحية القابلية للتنبؤ، لكنها تعني أن WASM لا يستطيع التكيف مع أنماط وقت التشغيل كما يفعل الـ JIT.

مشكلة عبور الحدود: الموت بألف استدعاء

هذه هي المشكلة الكبرى. كل استدعاء من JavaScript إلى WASM (أو العكس) له تكلفة. المحرك يجب أن يُحوّل البيانات (marshal)، ويتحقق من الأنواع، ويبدّل سياقات التنفيذ. العبور الواحد رخيص، بضعة ميكروثانية. لكن أحمال العمل التي تعبر الحدود آلاف المرات في كل عملية تُسحق بهذه التكلفة.

أرى هذا النمط باستمرار: فريق يكتب محللاً أو محولاً أو مدقِّقاً بلغة Rust، ويترجمه إلى WASM، ثم يغلّفه بشيفرة JavaScript لاصقة (glue code) تستدعي WASM لكل عقدة، ولكل token، ولكل خطوة. قد تكون نواة WASM سريعة جداً بمعزل عن غيرها، لكن شيفرة الربط تحوّلها إلى طريق سريع برسوم عبور.

// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source);          // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) {                     // JS land
wasmParser.transform(node, newValue);      // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.

قست هذا السيناريو بالضبط على قاعدة كود حقيقية. نسخة WASM عالجت دفعة من 100 ملف إعدادات في 340 ميلي ثانية. نسخة TypeScript أنجزتها في 195 ميلي ثانية. ليس لأن TypeScript لغة أسرع، فهي ليست كذلك، بل لأن العمل لم يغادر سياق تنفيذ واحد أبداً.

لماذا تدمّر النصوص أداء WASM

WASM يعمل على الذاكرة الخطية (linear memory)، وهي مصفوفة مسطحة من البايتات. أما نصوص JavaScript فشيء مختلف تماماً. هي كائنات يديرها المحرك ومخزنة بصيغ داخلية متخصصة (Latin1، وUTF-16، وropes، وcons strings). نقل نص من JS إلى WASM يعني ترميزه بـ UTF-8، وحجز مساحة في الذاكرة الخطية، ونسخ البايتات. واسترجاعه يعني نسخاً آخر وفك ترميز.

بالنسبة للشيفرة الحسابية الثقيلة لا يهم هذا. تمرر مصفوفة TypedArray وتحصل على أرقام. لكن لأي شيء يتعامل مع النصوص بكثافة، كالمحللات ومحركات القوالب والمدققات والمسلسلات (serializers)، أنت تدفع ضريبة الترميز-النسخ-المعالجة-النسخ-فك الترميز مع كل جزء نصي.

// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.

عندما حللت أداء محلل WASM لدينا، وجدت أن 31% من إجمالي زمن التنفيذ كان تسلسل النصوص (serialization). ليس التحليل. ولا بناء الشجرة. فقط نقل النصوص ذهاباً وإياباً عبر الحدود. نسخة TypeScript ألغت هذه الفئة كاملة من العمل.

إذا كان مسارك الساخن (hot path) كثيف النصوص، فغالباً WASM يجعله أبطأ لا أسرع. تكلفة التسلسل حقيقية وتتراكم بسرعة.

تجميع JIT: ميزة الأداء التي لا يتحدث عنها أحد

نموذج AOT في WASM يمنحك أداءً متوقعاً ومتسقاً. وهذا رائع لبعض الاستخدامات. لكن نموذج JIT في JavaScript يراقب شيفرتك وهي تعمل، ثم يولّد شيفرة آلة محسّنة مضبوطة على البيانات الفعلية التي تمر عبرها. ومع الوقت، قد تصبح الشيفرة المُجمّعة بالـ JIT سريعة بشكل مذهل.

خذ مثلاً تخصيص شكل الكائن (object shape specialization). إذا عالجت مصفوفة من الكائنات كلها تملك الخصائص نفسها بالترتيب نفسه، يكتشف V8 هذا النمط أحادي الشكل (monomorphic) ويولّد شيفرة آلة تصل إلى الخصائص عبر إزاحة ذاكرة ثابتة، بلا بحث في جدول hash ولا فحص للأنواع. إنه في الأساس أداء C-struct من داخل JavaScript الديناميكية.

// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number;   // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.

WASM لا يستطيع فعل ذلك. خصائص أدائه ثابتة وقت الترجمة. للحلقات العددية الضيقة والمتوقعة، لا بأس، فمترجم Rust حسّنها بالفعل إلى أقصى حد. أما الشيفرة الفوضوية متعددة الأشكال والكثيفة بالـ callbacks، وهي ما يميز معظم تطبيقات الويب، فقدرة الـ JIT على التخصص أثناء التشغيل ميزة حقيقية.

حجم الحزمة وزمن البدء البارد: المقاييس التي تنساها

الإنتاجية (throughput) ليست المقياس الوحيد للأداء. المستخدمون يلاحظون زمن التحميل، وتأخر التفاعل، والوقت حتى أول نتيجة. ولـ WASM تكاليف حقيقية في الثلاثة.

  • وحدة Rust مع wasm-bindgen تُشحن عادةً بحجم 100 كيلوبايت إلى 1.5 ميجابايت مضغوطة بـ gzip. المكافئ بـ TypeScript، مصغّراً ومضغوطاً، يكون غالباً بين 10 و40 كيلوبايت.
  • وحدات WASM يجب ترجمتها إلى شيفرة آلة أصلية قبل أن تعمل. البث المتدفق للترجمة (Streaming compilation) يساعد، لكنه يبقى عملاً على المتصفح أن يقوم به.
  • محركات JavaScript تحلل الكود بشكل كسول، فالدوال لا تُترجم حتى تُستدعى. أما WASM فيدفع تكلفة الترجمة كاملة مسبقاً.
  • تخزين الشيفرة المؤقت (code cache) في V8 يعني أن الزيارات المتكررة تتجاوز تحليل JS تماماً. تخزين مؤقت لترجمة WASM موجود، لكنه أقل نضجاً.

إليك مقارنة حقيقية من مشروعنا. حزمة محلل WASM كانت 380 كيلوبايت مضغوطة، وبديل TypeScript كان 26 كيلوبايت. على اتصال جيد، لا فرق كبير. أما على اتصال 3G مقيّد (وهو ما أحاكيه في كل مراجعة أداء)، أضافت نسخة WASM 1.8 ثانية إضافية إلى زمن التحميل. هذا ليس هامشاً بسيطاً، بل هو الفرق بين مستخدم يبقى ومستخدم يغادر.

زمن البدء البارد كان مهماً أيضاً. استغرقت وحدة WASM نحو 110 ميلي ثانية لتُترجم وتُنشأ على هاتف متوسط المواصفات. أما نسخة TypeScript فصارت تفاعلية في أقل من 20 ميلي ثانية. بالنسبة لأداة يلجأ إليها المستخدمون عشرات المرات يومياً، هذا يتراكم إلى إحباط حقيقي.

متى يتفوق WASM فعلاً (وينبغي أن تستخدمه)

لا أريد أن يُفهم من كلامي أن WASM تقنية سيئة. إنها تقنية رائعة لها نقطة قوة محددة. المشكلة أن الناس يستخدمونها خارج هذا المجال ثم يتساءلون لماذا أصبح كل شيء أبطأ.

يتفوق WASM عندما تكون الحسابات ثقيلة، وعمليات العبور قليلة، والبيانات عددية. فكّر في: مرشحات الصور التي تعمل على مصفوفات البكسلات، والتجزئة التشفيرية (hashing)، ومحاكاة الفيزياء، ومعالجة الصوت الرقمية (DSP)، وترميز الفيديو. تدفع كتلة كبيرة من البيانات إلى الذاكرة الخطية، فيسحقها WASM في حلقة ضيقة، ثم تسحب النتيجة. عبور واحد للداخل وآخر للخارج، وحسابات كثيرة بينهما. هنا يلمع WASM.

يتفوق WASM أيضاً عندما تحتاج إلى أداء حتمي. الـ JIT قوي لكنه غير متوقع، فأول بضعة استدعاءات لدالة JS تعمل مفسَّرةً (interpreted)، ثم تُترجم ترجمة أساسية (baseline)، وربما ترجمة محسِّنة بعد ذلك. إذا كنت تحتاج زمن استجابة ثابتاً منذ الاستدعاء الأول (حلقات الألعاب، والصوت الفوري، والحسابات المالية)، فالترجمة المسبقة في WASM تمنحك ذلك.

وبالطبع WASM هو الخيار الصحيح عندما تنقل قاعدة كود أصلية موجودة. لا ينبغي لأحد أن يعيد كتابة SQLite بلغة JavaScript لتجنب تكلفة WASM. هذا جنون. الشيفرة C الموجودة تمثل عقوداً من التحسين لن تستطيع تكراره.

إطار قرار عملي: WASM أم TypeScript؟

بعد تطبيق هذه التجربة على ثلاثة مشاريع، وصلت إلى قائمة تحقق بسيطة. ليست مثالية، لكنها أنقذتني من اتخاذ القرار الخاطئ مرتين حتى الآن.

  1. احسب عمليات العبور. إذا كانت وحدة WASM تستدعي JS (أو العكس) أكثر من بضع مئات المرات في كل عملية، فغالباً ستفوز TypeScript. قِس ذلك بالتحليل للتأكد.
  2. انظر إلى أنواع بياناتك. هل هي في الغالب أرقام ومصفوفات TypedArray؟ إذن WASM مناسب. هل هي في الغالب نصوص وكائنات و callbacks؟ ابقَ في JS.
  3. قِس تكلفة البدء. إذا كانت الشيفرة تعمل عند تحميل الصفحة أو استجابةً لنقرات المستخدم، فتكلفة الترجمة في WASM مهمة. إذا كانت تعمل في Worker طويل العمر، فالبدء يتوزع ويذوب.
  4. تحقق من ميزانية الحزمة. إذا كنت تعاني أصلاً من حجم الحزمة، فإضافة وحدة WASM بحجم 400 كيلوبايت ستؤذيك.
  5. فكّر في تكلفة تجربة المطور (DX). يضيف WASM نظام بناء بلغة Rust أو C++، وتعقيد خرائط المصدر (source maps)، وتصحيح أخطاء أصعب. إذا كان مكسب الأداء هامشياً، فالتعقيد لا يستحق.
  6. قِس باستخدام بيانات حقيقية. القياسات الدقيقة (microbenchmarks) مضللة. أنماط التكلفة لا تظهر إلا مع مدخلات بحجم الإنتاج وأنماط استدعاء واقعية.

ما تعلمته من إعادة الكتابة: قبل وبعد

دعني أعرض الأرقام الفعلية من ترحيل المحلل لدينا. هذه ليست قياسات اصطناعية، بل نتائج معالجة مجموعة مستنداتنا الحقيقية، 200 ملف تتراوح بين 50 و2,000 سطر.

Metric                  | Rust/WASM    | TypeScript   | Delta
------------------------|--------------|--------------|--------
Median parse time       | 23.1ms       | 14.4ms       | -38%
P99 parse time          | 58.3ms       | 30.7ms       | -47%
Bundle size (gzip)      | 380KB        | 26KB         | -93%
Cold start              | 142ms        | 18ms         | -87%
Boundary crossings/doc  | ~12,000      | 0            | -100%
String serialization    | 31% of time  | 0%           | eliminated
Debug/profile ease      | painful      | native       | huge win

إعادة كتابة TypeScript لم تكن نقلاً مباشراً. أعدت تصميم تمثيل شجرة الصياغة المجردة (AST) ليتناغم مع محسِّن V8: أشكال كائنات أحادية الشكل، وتعدادات رقمية (enums) بدلاً من وسوم نصية، وتخزين مصفوفي مسطح بدلاً من أشجار كثيفة المؤشرات. هذه تحسينات لن تفكر فيها أبداً في Rust لأن مترجم Rust يتولى هذا المستوى من التفاصيل. في JavaScript عليك أن تقطع نصف الطريق نحو الـ JIT.

// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number;       // numeric, not string — cheaper comparisons
start: number;      // byte offset
end: number;
parent: number;     // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number;      // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}

الفكرة الأساسية؟ أسرع شيفرة ليست المكتوبة باللغة الأسرع. بل هي الشيفرة التي تعمل مع بيئة تشغيلها لا ضدها. كانت شيفرة Rust لدينا ممتازة كشيفرة Rust. لكن بعد ترجمتها إلى WASM وتضمينها في تطبيق JavaScript، صارت تقاتل المنصة في كل خطوة.

توقف عن الافتراض، وابدأ بالقياس

ما زلت أستخدم WASM. لدي خط معالجة صور مبني على WASM أسرع بأربع مرات من أي شيء أستطيع كتابته بـ JavaScript، لأنه يعمل على مصفوفات بكسلات خام دون أي عبور للحدود. الأداة الصحيحة للمهمة الصحيحة.

لكنني توقفت عن اللجوء إلى WASM تلقائياً عندما أحتاج إلى الأداء. افتراضي الجديد هو كتابة نسخة TypeScript أولاً، وقياسها ببيانات حقيقية، ولا ألجأ إلى WASM إلا عندما يخبرني المُحلِّل بأنني أحتاجه، وفقط للمسار الساخن المحدد الذي هو بطيء فعلاً. ليس الوحدة كلها. ولا الميزة كلها. فقط الحلقة الحسابية الضيقة التي تستفيد فعلاً من الترجمة المسبقة.

اكتبه بـ TypeScript. قِسه. إذا كان سريعاً بما يكفي، فأطلقه. وإذا لم يكن، فانقل الحلقة الساخنة، والحلقة الساخنة وحدها، إلى WASM. ستنتهي بشيفرة أقل، وحزم أصغر، وتطبيقات أسرع.

تمنحك منصة الويب نموذجَي تنفيذ قويين. استخدمهما كليهما. لكن استخدمهما حيث يفيدان فعلاً، لا حيث يقول لك حدسك إنهما يجب أن يفيدا.