اسمي Valery Shavel ، أنا من فريق التطوير لمحرك متجه Yandex.Maps. في الآونة الأخيرة ، قمنا بتطبيق تقنية WebAssembly في المحرك. فيما يلي سوف أخبرك لماذا اخترناها ، وما هي النتائج التي حصلنا عليها وكيف يمكنك استخدام هذه التكنولوجيا في مشروعك.

في Yandex.Maps ، تتكون خريطة المتجهات من قطع تسمى البلاطات. في الواقع ، فإن البلاط هو المساحة المفهرسة للخريطة. الخريطة متجهة ، لذلك يحتوي كل بلاطة على العديد من الأوليات الهندسية. يأتي البلاط من الخادم إلى العميل المشفر ، وقبل عرضه ، من الضروري معالجة جميع العناصر الأولية. في بعض الأحيان يستغرق قدرا كبيرا من الوقت. يمكن للبلاط أن يحتوي على أكثر من ألفي polylines ومضلعات.

عند معالجة البدائل ، يكون الأداء أكثر أهمية. إذا لم يتم إعداد التجانب بسرعة كافية ، فسيرى المستخدم بعد فوات الأوان ، وسيتم تأجيل التجانب التالي في قائمة الانتظار. لتسريع المعالجة ، قررنا تجربة
تقنية WebAssembly (Wasm) الجديدة نسبيًا.
استخدام WebAssembly في الخرائط
الآن تتم معالجة معظم العناصر الأولية في خيط المناقشة (Web Worker) الذي يعيش حياة منفصلة. يتم ذلك من أجل تفريغ الخيط الرئيسي قدر الإمكان. وبالتالي ، عندما يتم تضمين رمز إظهار البطاقة في صفحة الخدمة ، والتي يمكن أن تضيف حمولة كبيرة بحد ذاتها ، سيكون هناك عدد أقل من الفرامل. الجانب السلبي هو أنك تحتاج إلى تكوين الرسائل بشكل صحيح بين مؤشر الترابط الرئيسي وعامل الويب.
يتكون جزء المعالجة الذي يحدث في مؤشر ترابط الخلفية بشكل أساسي من خطوتين:
- يتم فك ترميز تنسيق protobuf الذي يأتي من الخادم.
- يتم إنشاء الهندسة وكتابتها إلى المخازن المؤقتة.
في الخطوة الثانية ،
يتم تكوين مخازن رأسية وفهرس لـ
WebGL . يتم استخدام هذه المخازن المؤقتة عند التقديم كما يلي. يحتوي المخزن المؤقت في قمة الرأس على كل قمة معلماته ، والتي هي ضرورية لتحديد موقعها على الشاشة في لحظة معينة. يتكون المخزن المؤقت الفهرس من ثلاثة أضعاف الفهارس. تعني كل ثلاثية أن مثلثًا به رؤوس من المخزن المؤقت لرأس الرأس في المؤشرات المشار إليها يجب أن يتم عرضه على الشاشة. لذلك ، يجب تقسيم البدائية إلى مثلثات ، والتي يمكن أن تكون أيضًا مهمة تستغرق وقتًا طويلاً:

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

لم نكن راضين عن أداء شفرة JavaScript الخاصة بنا. في هذا الوقت ، بدأ الجميع الكتابة عن WebAssembly ، وكانت التكنولوجيا تتطور باستمرار وتتحسن. بعد قراءة البحث ، اقترحنا أن Wasm يمكنه تسريع عملياتنا. على الرغم من أننا لم نكن متأكدين تمامًا من هذا: فقد تبين أنه من الصعب العثور على بيانات حول استخدام Wasm في مثل هذا المشروع الكبير.
Wasm أسوأ أيضًا إلى حد ما من TypeScript:
- نحن بحاجة إلى تهيئة وحدة خاصة مع الوظائف التي نحتاجها. قد يتسبب هذا في تأخير قبل أن تبدأ هذه الوظيفة في العمل.
- حجم شفرة المصدر عند ترجمة Wasm أكبر بكثير من TS.
- يتعين على المطورين دعم إصدار بديل من تنفيذ التعليمات البرمجية ، والذي يتم كتابته أيضًا بلغة غير نمطية للواجهة الأمامية.
ومع ذلك ، على الرغم من كل هذا ، فقد خاطرنا بإعادة كتابة جزء من التعليمات البرمجية الخاصة بنا باستخدام Wasm.
WebAssembly معلومات عامة

Wasm هو تنسيق ثنائي؛ يمكنك تجميع لغات مختلفة فيه ، ثم تشغيل الشفرة في المستعرض. غالبًا ما تكون هذه التعليمات البرمجية المترجمة مسبقًا أسرع من JavaScript الكلاسيكية. لا تملك التعليمات البرمجية بتنسيق WebAssembly الوصول إلى عناصر DOM بالصفحة ، وكقاعدة عامة ، يتم استخدامها لتنفيذ المهام الحسابية المستهلكة للوقت على العميل.
لقد اخترنا لغة C ++ كلغة مترجمة ، لأنها مريحة وسريعة للغاية.
لتجميع C ++ في WebAssembly ، استخدمنا
emscripten . بعد تثبيته وإضافته إلى مشروع C ++ ، من أجل الحصول على الوحدة النمطية ، تحتاج إلى كتابة ملف المشروع الرئيسي بطريقة معينة. على سبيل المثال ، قد يبدو كالتالي:
#include <emscripten/bind.h> #include <emscripten.h> #include <math.h> struct Point { double x; double y; }; double sqr(double x) { return x * x; } EMSCRIPTEN_BINDINGS(my_value_example) { emscripten::value_object<Point>("Point") .field("x", &Point::x) .field("y", &Point::y) ; emscripten::register_vector<Point>("vector<Point>"); emscripten::function("distance", emscripten::optional_override( [](Point point1, Point point2) { return sqrt(sqr(point1.x - point2.x) + sqr(point1.y - point2.y)) ; })); }
بعد ذلك ، سوف أصف كيف يمكنك استخدام هذا الرمز في مشروع TypeScript الخاص بك.
في الكود ، نعرّف بنية Point ونضعها في واجهة Point في TypeScript ، حيث سيكون هناك حقلان - x و y ، يتطابقان مع حقول الهيكل.
علاوة على ذلك ، إذا أردنا إرجاع حاوية المتجهات القياسية من C ++ إلى TypeScript ، فإننا نحتاج إلى تسجيلها لنوع Point. ثم في TypeScript ، تتوافق الواجهة مع الوظائف الضرورية.
وأخيرًا ، يوضح الرمز كيفية تسجيل وظيفتك من أجل الاتصال بها من TypeScript بالاسم المقابل.
ترجمة الملف باستخدام emscripten وإضافة الوحدة النمطية الناتجة إلى مشروع TypeScript الخاص بك. الآن يمكننا كتابة ملف d.ts العام لوحدة emscripten التعسفية ، والتي يتم فيها تحديد الوظائف والأنواع المفيدة مسبقًا:
declare module "emscripten_module" { interface EmscriptenModule { readonly wasmMemory: WebAssembly.Memory; readonly HEAPU8: Uint8Array; readonly HEAPF64: Float64Array; locateFile: (path: string) => string; onRuntimeInitialized: () => void; _malloc: (size: size_t) => uintptr_t; _free: (addr: size_t) => uintptr_t; } export default EmscriptenModule; export type uintptr_t = number; export type size_t = number; }
ويمكننا كتابة ملف d.ts لوحدة لدينا:
declare module "emscripten_point" { import EmscriptenModule, {uintptr_t, size_t} from 'emscripten_module'; interface NativeObject { delete: () => void; } interface Vector<T> extends NativeObject { get(index: number): T; size(): number; } interface Point { readonly x: number; readonly y: number; } interface PointModule extends EmscriptenModule { distance: (point1: Point, point2: Point) => number; } type PointModuleUninitialized = Partial<PointModule>; export default function createModuleApi(Module: Partial<PointModule>): PointModule; }
الآن يمكننا كتابة وظيفة ستنشئ وعدًا لتهيئة الوحدة النمطية واستخدامها:
import EmscriptenModule from 'emscripten_module'; import createPointModuleApi, {PointModule} from 'emscripten_point'; import * as pointModule from 'emscripten_point.wasm'; export default function initEmscriptenModule<ModuleT extends EmscriptenModule>( moduleUrl: string, moduleInitializer: (module: Partial<EmscriptenModule>) => ModuleT ): Promise<ModuleT> { return new Promise((resolve) => { const module = moduleInitializer({ locateFile: () => moduleUrl, onRuntimeInitialized: function (): void {
الآن للحصول على هذا الوعد نحصل على وحدة لدينا جنبا إلى جنب مع وظيفة المسافة.
لسوء الحظ ، لا يمكنك تصحيح سطر رمز Wasm سطراً في المستعرض. لذلك ، من الضروري كتابة الاختبارات وتشغيل التعليمات البرمجية عليها مثل C ++ العادي ، ثم ستتاح لك فرصة تصحيح الأخطاء بشكل مناسب. ومع ذلك ، حتى في المستعرض ، يمكنك الوصول إلى دفق cout القياسي ، والذي سينتج كل شيء إلى وحدة تحكم المستعرض.
يتوفر مثال لمشروع من المقالة عبر هذا
الرابط ، حيث يمكنك رؤية إعدادات webpack.config و CMakeLists.
النتائج
لذلك ، أعدنا كتابة جزء من الكود الخاص بنا وبدأنا تجربة للنظر في تحليل المضلعات والمضلعات. يوضح الرسم البياني النتائج المتوسطة لبلاط واحد لـ Wasm و JavaScript:

نتيجة لذلك ، حصلنا على مثل هذه المعاملات النسبية لكل مقياس:

كما ترون من وقت تحليل البدائي الخالص ووقت فك تشفير البلاط ، فإن Wasm أسرع بأربع مرات. إذا نظرت إلى إجمالي وقت التحليل ، فإن الفرق كبير أيضًا ، لكنه لا يزال أقل من ذلك بقليل. هذا يرجع إلى تكلفة نقل البيانات إلى Wasm وجمع النتيجة. تجدر الإشارة أيضًا إلى أن الكسب الإجمالي مرتفع للغاية (في العشرة الأولى - أكثر من خمس مرات). ومع ذلك ، فإن المعامل النسبي ينخفض إلى حوالي ثلاثة.
نتيجة لذلك ، ساعد كل هذا معًا على تقليل وقت معالجة بلاطة واحدة في خيط المناقشة بنسبة 20-25٪. بالطبع ، ليس هذا الاختلاف كبيرًا عن الفوارق السابقة ، ولكن عليك أن تفهم أن تحليل الخطوط المكسورة والمضلعات بعيدًا عن معالجة البلاط.
إذا تحدثنا عن الحاجة إلى تهيئة الوحدة النمطية ، بسببها ، فقد تأخر حوالي نصف المستخدمين قبل تحليل التجانب الأول. التأخير الوسيط هو 188 مللي ثانية. يحدث التأخير فقط قبل التجانب الأول ، والفوز في التحليل ثابت ، حتى تتمكن من الوقوف مع توقف صغير في البداية وعدم اعتباره مشكلة خطيرة.
الجانب السلبي الآخر هو حجم ملف التعليمات البرمجية المصدر. رمز مصغر مضغوط بتنسيق Gzip لكامل محرك خريطة المتجه بدون Wasm - 85 كيلو بايت ، مع Wasm - 191 كيلو بايت. في الوقت نفسه ، يتم تطبيق تحليل الخطوط والمستطيلات المكسورة فقط في Wasm ، وليس كل العناصر الأولية التي يمكن أن تكون في بلاطة. علاوة على ذلك ، لفك تشفير protobuf ، كان عليّ اختيار تطبيق مكتبة في C خالص ، مع تطبيق C ++ كان الحجم أكبر. يمكن تقليل هذا الاختلاف إلى حد ما باستخدام علامة برنامج التحويل البرمجي -Oz بدلاً من -O3 عند التحويل البرمجي C ++ ، لكنه لا يزال مهمًا. بالإضافة إلى ذلك ، مع هذا الاستبدال ، نفقد الإنتاجية.
ومع ذلك ، فإن حجم المصدر لم يؤثر بشكل كبير على سرعة تهيئة البطاقة. Wasm أسوأ فقط على الأجهزة البطيئة والفرق أقل من 2٪. ولكن تم عرض المجموعة المرئية المبدئية من مربعات المتجهات في التنفيذ باستخدام Wasm للمستخدمين بشكل أسرع قليلاً من تطبيق JS. ويرجع ذلك إلى المكسب الأكبر على البلاطات المعالجة الأولى ، في حين لم يتم تحسين JS بعد.
وبالتالي ، أصبح Wasm خيارًا جيدًا إذا لم تكن راضيًا عن أداء تعليمات JavaScript البرمجية. في الوقت نفسه ، يمكنك الحصول على مكاسب أداء أقل منا ، أو لا يمكنك الحصول عليها على الإطلاق. هذا يرجع إلى حقيقة أن جافا سكريبت نفسها تعمل في بعض الأحيان بسرعة كبيرة ، وفي Wasm تحتاج إلى نقل البيانات وجمع النتيجة.
تعمل خرائطنا الآن على تشغيل جافا سكريبت عادي. ويرجع ذلك إلى حقيقة أن المكسب في التحليل ليس كبيرًا جدًا في ظل الخلفية العامة ، ويرجع ذلك إلى حقيقة أن بعض أنواع الأوليات فقط يتم تنفيذها في Wasm. إذا تغير هذا الأمر ، فربما سنستخدم Wasm. حجة قوية أخرى ضد هذا هي تعقيد التجميع والتصحيح: دعم مشروع بلغتين أمر منطقي فقط عندما يكون كسب الأداء يستحق ذلك.