Writing an interpreter (virtual machine) for a simple byte-code + JIT compilation

There are two articles on Russian, the author of which writes a virtual machine (interpreter) for executing a simple bytecode and then applies different optimizations to make this virtual machine faster. Besides that, there is a compiler of a simple C-like language into this bytecode. After reading this article and getting familiar with the compiler, I thought that it would be interesting to try writing a virtual machine for this language that would be able to apply JIT compilation to this bytecode with the libjit library. This article describes the experience of doing that.

I found several articles online that describe the usage of this library, but those that I saw, describe the compilation of concrete programs with libjit, while I was interested in compiling arbitrary bytecode. For people interested in further reading, there is an official tutorial, a series of articles and a series of comparisons (in Russian).

The implementation was done in C++ because we aren`t playing games here. All my code is in my repository. The "main" branch has just the interpreter of the PigletVM bytecode; "labels-with-fallbacks" has a partial JIT compilation implementation (that doesn`t support JUMP instructions), "full-jit" has fully working JIT compilation; "making-jit-code-faster" makes code generated by JIT work faster and "universal-base-vm*" branches merge the interpreter and JIT compilation implementations, by implementing a base generalised executor, which can be used for different implementations of PigletVM (both the interpreter and libjit compilation)

Compiling to bytecode

As I mentioned earlier, PigletC can compile a simple C-like language into PigletVM bytecode in TEXT format. First, I will need a program that will translate bytecode from text format to binary.

Some details:

Otherwise, there is nothing really interesting in the code of the assembler. It can be seen here.

Interpreting byte code

Now it is time to write a bytecode interpreter so that we have something to compare the execution of JIT compiled code with.

The state of the virtual machine is defined by the following elements:

Initially, we could use STL stack and vector containers for the stack and memory, respectively (or we could use vector or deque for both) - then the sizes of stack and memory won`t need to be bounded by some constants. However, I ended up using raw pointers instead, because STL containers wouldn`t work well with JIT compilation (technically, we can call arbitrary functions from JIT compiled code, but inlining STL container methods would be impossible, which would cause dramatic performance).

All in all, the implementation of the interpreter is straightforward: it is a big switch that executes each instruction:

code
void store_to_memory(int addr, int value) {
        memory[addr] = value;
    }

int stack_pop() {
    return stack[--stack_size];
}

void run() {
        size_t ip = 0;
        while (ip < instructions_number) {
            ip++;
            switch (instructions[ip - 1]) {
                case OP_JUMP: {
                    auto arg = instructions[ip];
                    ip++;
                    ip = arg;
                    break;
                }
                case OP_JUMP_IF_TRUE: {
                    auto arg = instructions[ip];
                    ip++;
                    if (stack_pop()) {
                        ip = arg;
                    }
                    break;
                }
                case OP_JUMP_IF_FALSE: {
                    auto arg = instructions[ip];
                    ip++;
                    if (!stack_pop()) {
                        ip = arg;
                    }
                    break;
                }
                case OP_LOADADDI: {
                    auto arg = instructions[ip];
                    ip++;
                    stack[stack_size - 1] += memory[arg];
                    break;
                }
                case OP_LOADI: {
                    auto arg = instructions[ip];
                    ip++;
                    stack[stack_size++] = memory[arg];
                    break;
                }
                case OP_PUSHI: {
                    auto arg = instructions[ip];
                    ip++;
                    stack[stack_size++] = arg;
                    break;
                }
                case OP_DISCARD: {
                    stack_size--;
                    break;
                }
                case OP_STOREI: {
                    auto addr = instructions[ip];
                    ip++;
                    store_to_memory(addr, stack_pop());
                    break;
                }
                case OP_LOAD: {
                    auto addr = stack_pop();
                    stack[stack_size++] = memory[addr];
                    break;
                }
                case OP_STORE: {
                    auto val = stack_pop();
                    auto addr = stack_pop();
                    store_to_memory(addr, val);
                    break;
                }
                case OP_ADDI: {
                    auto arg = instructions[ip];
                    ip++;
                    stack[stack_size - 1] += arg;
                    break;
                }
                case OP_DUP: {
                    stack[stack_size] = stack[stack_size - 1];
                    stack_size++;
                    break;
                }
                case OP_SUB: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] -= arg;
                    break;
                }
                case OP_ADD: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] += arg;
                    break;
                }
                case OP_DIV: {
                    auto arg = stack_pop();
                    if (arg == 0) {
                        cerr << "ZERO DIVISION\n";
                        return;
                    }
                    stack[stack_size - 1] /= arg;
                    break;
                }
                case OP_MUL: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] *= arg;
                    break;
                }
                case OP_ABORT: {
                    cerr << "OP_ABORT called\n";
                    return;
                }
                case OP_DONE: {
                    cout << "program DONE\n";
                    return;
                }
                case OP_PRINT: {
                    cout << stack_pop() << "\n";
                    break;
                }
                case OP_POP_RES: {
                    stack_pop();
                    break;
                }
                case OP_GREATER_OR_EQUALI: {
                    auto arg = instructions[ip];
                    ip++;
                    stack[stack_size - 1] = stack[stack_size - 1] >= arg;
                    break;
                }
                case OP_GREATER_OR_EQUAL: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] = stack[stack_size - 1] >= arg;
                    break;
                }
                case OP_GREATER: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] = stack[stack_size - 1] > arg;
                    break;
                }
                case OP_LESS: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] = stack[stack_size - 1] < arg;
                    break;
                }
                case OP_LESS_OR_EQUAL: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] = stack[stack_size - 1] <= arg;
                    break;
                }
                case OP_EQUAL: {
                    auto arg = stack_pop();
                    stack[stack_size - 1] = stack[stack_size - 1] == arg;
                    break;
                }
                case 0xcafe: {
                    // skip 0xbabe
                    ip++;
                }
            }
        }
  }

Moving to JIT compilation

Since I write in C++, I will use the version of libjit for this language, with blackjack and hookers classes and inheritance.

The way libjit works is that you first generate some intermediate representation (IR) of the code of your function and then libjit translates this representation into machine code. The IR is generated by calling libjit functions.

In order to create a JIT compilable function, you need to define a class inherited from jit_function, define a constructor and override the build and create_signature methods. In reality, a PigletC program only has one function and the bytecode doesn`t have instructions for function calling and returning from functions (and since JUMP instructions can only jump to addresses known at compile-time, we can`t have functions without additional support from the bytecode). Regardless, we can define a universal create_signature function:

jit_type_t create_signature() override {
        auto ret_type = (is_void ? jit_type_void : jit_type_int);
        jit_type_t params[num_args];
        for (int i = 0; i < num_args; i++) {
            params[i] = jit_type_int;
        }
        return jit_type_create_signature(jit_abi_cdecl, ret_type, params, num_args, 1);
}

Above, we consider a function to be able to accept a fixed number of int arguments and return either int or void.

Now the rest we need to do is to create a function based on bytecode. Here I spent some time thinking what to do with the stack. When the interpreter is executing code, the stack is the array/vector/std::stack, defined in the virtual machine itself. When it comes to JIT compilation, I thought it would be efficient to use the real stack (one referenced by the %rspregister on x86). However, I didn`t find libjit methods that would allow this: firstly, the library is meant to be cross-platform and the stack might be different on different architectures, and secondly, libjit is going to use the stack itself and wouldn`t give direct access to it.

Here, it is important to clearly understand, which code is executed when we are creating the IR and which - while the JIT compiled function is executed. For example, one might want to create an array of jit_type_int values in the build function for the stack. But the indexes on the stack aren`t known at compile time, so that is impossible.

Given that, we will pass the pointers to memory, stack and stack size into the constructor of the JIT compiled function and use them in the same way, as in the interpreter. A side effect of this is that it will be possible to switch between function execution in JIT mode and interpreting mode - and if JIT compilation doesn`t support some of the instructions, it will return to the interpreter on finding such an instruction, which will execute the unsupported instructions. For now, JIT compilaion won`t support JUMP instructions.

Another option would be calling malloc from the JIT-function and using the allocated memory for the stack.

Getting ready for function compilation will look this way:

// creating a poiner to int* and writing the outer pointer to memory into it
auto memory = (jit_type_create_pointer(jit_type_int, 1));
memory = this->new_constant(memory_outer);
// doing the same for the stack
auto stack = new_value(jit_type_create_pointer(jit_type_int, 1));
stack = this->new_constant(stack_outer);
// creating a pointer for stack size
jit_value stack_size_ptr = new_value(jit_type_create_pointer(jit_type_int, 1));
stack_size_ptr = this->new_constant(stack_size_ptr_outer);
// convenience wrapper over the function for pushing 
// to the stack
auto push = [this, &stack, &stack_size_ptr] (jit_value&& value) {
    push_on_stack(stack, stack_size_ptr, std::move(value));
};
// same for stack pop
auto pop = [this, &stack, &stack_size_ptr] () {
    return pop_from_stack(stack, stack_size_ptr);
};
int& ip = *ip_ptr;

And somewhere below:

void push_on_stack(jit_value& stack, jit_value& stack_size_ptr, jit_value&& arg) {
    insn_store_elem(stack, insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int), arg);
    insn_store_elem(stack_size_ptr, new_constant(0),
                        insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int) + new_constant(1));
}

jit_value pop_from_stack(jit_value& stack, jit_value& stack_size_ptr) {
    insn_store_elem(stack_size_ptr, new_constant(0),
                    insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int) - new_constant(1));
    return insn_load_elem(stack, insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int), jit_type_int);
}

jit_value peek_stack(jit_value& stack, jit_value& stack_size_ptr) {
    return insn_load_elem(stack, insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int) - new_constant(1),
                          jit_type_int);
}

All the variables and values that a JIT compiled function can use, should be declared as variables of jit_value type.

For convenience, we need to write functions that will "push values on the stack" and "pop values from the stack". They are in quotes because in reality, they won`t be doing that - they will be adding corresponding actions to the IR of the function.

Now the processing of bytecode instructions is relatively easy. Some examples:

case OP_STORE: {
    auto val = pop();
    auto index = pop();
    insn_store_elem(memory, index, val);
    break;
}
case OP_LOAD: {
    auto index = pop();
    push(insn_load_elem(memory, index, jit_type_int));
    break;
}
case OP_PRINT: {
    auto tmp = pop().raw(); // .raw returns a C struct from the C++ wrapper
    this->insn_call_native("print_int", (void*)(&print_int), signature_helper(jit_type_void, jit_type_int, end_params),
        &tmp, 1, 0);
    break;
}

In the implementation of the third instruction, we can see how to call a native function, i. e., compiled into machine code without libjit.

Here it can be noted that the code of creating libjit IR is quite similar to the interpreter code. It seems that we could make the interpreter and JIT compilation share the code for most of the instructions - and we will do this later. This would allow us not to do double work when adding new instructions.

The virtual machine will now check if the next instruction is a JUMP. If yes, it will be executed. Otherwise, JIT compilation will be applied to the following instructions until we reach another JUMP. The JIT compiled code will be saved into the cache and reused.

Naturally, this works very slowly - the parts that we compile, are relatively small, and the interpreter is called quite often. For now this is just a demonstration of what we can do with libjit.

Now we need to add JUMP instructions support. In libjet, besides variables, we can create labels and do two operations with them: setting where the label is to be placed in the code and define JUMPS to this label.

Now we will create a dictionary with the mapping from instruction addresses to labels. When we encounter 0xcafe - we put a label right there. When we meet a JUMP instruction - we set that there should be a JUMP to the label that we get from the dictionary by the corresponding instruction address. Code:

case 0xcafe: {
    // skipping 0xbabe
    ip++;
    // ip is now pointing to the instruction after the label
    if (labels.count(ip) == 0) {
        labels[ip] = jit_label_undefined;
    }
    insn_label(labels[ip]);
    break;
}
case OP_JUMP: {
    auto arg = instructions[ip];
    ip++;
    if (labels.count(arg) == 0) {
        labels[arg] = jit_label_undefined;
    }
    insn_branch_if(new_constant(true), labels[arg]);
    break;
}
case OP_JUMP_IF_TRUE: {
    auto arg = instructions[ip];
    ip++;
    if (labels.count(arg) == 0) {
        labels[arg] = jit_label_undefined;
    }
    insn_branch_if(pop(), labels[arg]);
    break;
}
case OP_JUMP_IF_FALSE: {
    auto arg = instructions[ip];
    ip++;
    if (labels.count(arg) == 0) {
        labels[arg] = jit_label_undefined;
    }
    insn_branch_if_not(pop(), labels[arg]);
    break;
}

At this point we can start the virtual machine for executing some simple code, expecting it to be way faster with JIT compilation. However, our JIT compilation only made the code slower - 1.2 seconds with the interpreter against 1.4 with JIT compilation!

What is wrong? The problem is that we generate code that is kind of complicated (because I expected the optimizer to endure everything), while the libjit optimizer is relatively weak and can`t simplify it.

Let`s try optimizing the code we generate.

The implementation of bytecode instructions is relatively simple - while the operations with the stack look kind of verbose, because we not only increment or decrement the variable with stack size - we read it from some address - and then write it back - and libjit is probably not able to put stack size on the register. Let`s simplify this by making stack size a local variable. We could still save the ability of the interpreter and the JIT compiler to work together - if we save stack size before the JIT compiled part returns.

jit_value stack_size = new_value(jit_type_int);
stack_size = new_constant(0);
auto push = [this, &stack, &stack_size](jit_value&& value) {
    insn_store_elem(stack, stack_size, value);
    stack_size = stack_size + new_constant(1);
};
auto pop = [this, &stack, &stack_size]() {
    stack_size = stack_size - new_constant(1);
    return insn_load_elem(stack, stack_size, jit_type_int);
};
auto peek = [this, &stack, &stack_size]() {
    return insn_load_elem(stack, stack_size - new_constant(1), jit_type_int);
};

This time performance improvement is there and is significant: 1.2 seconds vs 0.4 seconds.

On the other hand, we could expect a more significant improvement. I suspect that weak libjit optimizations combined with the fact that PigletVM is a stack machine, not a register machine, prevent the code from being really fast - bytecode instructions are using the stack all the time which is way slower than using registers. This problem could be partly solved by replacing some sequences of instructions with more complex instructions (like the author of the original PigletVM articles introduced PUSHI, STOREI, LOADADDI, etc)

When measuring performance, I was testing bytecode resulting from the compilation of the following PigletC program:

int res;
int i;

void main() {
    i = 1000000000;
    while (i > 0) {
        i = i - 1;
    }
    print(i);
}

@vda19999
31.08.2023 09:38 UTC
Первоисточник

Комментарии

@simulatorapk
01.09.2023 05:06 UTC
-1

The psychological aspect of 롤배팅 cannot be ignored. The rush of anticipation and the thrill of seeing one's predictions play out contribute to heightened engagement.

@simulatorapk
17.09.2023 18:20 UTC
0

Pasar123 login likely refers to a login process or page associated with the platform or service called "Pasar123." Unfortunately, as of my last knowledge update in September 2021, I don't have specific information about a platform or service by that name. However, based on the term, it appears to involve logging into an online platform or system, potentially related to markets, e-commerce, or a similar domain. To provide more accurate information, details about the specific platform, its purpose, and the login process would be needed.

@simulatorapk
30.09.2023 21:56 UTC
0

สมัคร UFABET เว็บแม่ กับเว็บไซต์ UFABETWINS เว็บพนันบอล ดีที่สุด สำหรับนักพนันมือใหม่ที่ www.ufabet ต้องการ สมัครUFABET เว็บตรง เว็บพนันไม่ผ่านเอเย่นต์ คาสิโนออนไลน์UFABET ที่เล่นกับ เว็บตรงและเว็บหลัก ซึ่งเป็น เว็บพนันบอลดีที่สุดไม่ผ่านเอเย่นต์ จะทางเลือกที่ดีที่สุดที่อยาก แนะนำ เว็บไซต์พนันออนไลน์UFABET เว็บแทงบอลUFABET ที่สามารถให้ทุกคนได้ผลกำไรจากการเดิมพันเพราะเราเป็น เว็บตรงยอดนิยม อันดับ 1 เป็นตัวแทนที่ให้นักพนันนิยมใช้บริการมากที่สุดและสามารถใช้งานตลอด 24 ชั่วโมงและติดต่อกับเว็บได้ตลอดเวลารวมถึงการ สมัครเล่นบอลออนไลน์ เป็นสมาชิกกับเว็บก็ง่ายๆ เพราะ UFABETสมัครสมาชิก เราอำนวยความสะดวกด้วยความจริงใจด้วยเร็วและครบวงจรเป็นเว็บที่สามารถเปิดบริการเล่น UFABETไม่ผ่านเอเย่น ฝากถอนแบบ UFABETสมัคร ไม่มีขั้นต่ํา สามารถใช้งานผ่านโทรศัพท์มือถือได้ตลอดเวลาเราเป็นเว็บที่สุดปังในเรื่องการจ่ายเยอะและจ่ายจริงไม่มีการโกงตอบโจทย์นักพนันที่ทันสมัยที่ต้องการความรวดเร็วทันใจซึ่งเป็นเว็บที่สามารถร่วมสนุกได้ทุกที่บนโทรศัพท์มือถือและเป็น

@simulatorapk
06.10.2023 08:12 UTC
0

Fortunabola selalu menyediakan transaksi permainan selama 24 jam online, maka akan memberikan peluang untuk para pemain mengantongi penghasilan tambahan secara efektif. Tentu saja para member tidak perlu merasa khawatir untuk menjalani taruhan dalam sepanjang waktu karena terdapat fasilitas live chat 24 jam online. Yang mana para member bisa melaporkan kendala ataupun mengajukan pertanyaan secara langsung pada customer service yang tersedia dalam setiap waktu. https://128.199.199.25/

@simulatorapk
09.10.2023 09:10 UTC
0

When it comes to staying updated on sports events, nothing beats كورة شوت. Our website, كورة شوت, provides you with real-time scores, insightful analyses, and exclusive interviews with athletes. Whether you're a football fanatic or a basketball buff, our comprehensive coverage has something for everyone.

@simulatorapk
18.10.2023 12:58 UTC
0

롤토토추천 모든 스포츠에 토토 및 도박이 존재하듯이 E-스포츠 또한 토토 및 도박이 있습니다 .E스포츠토토는 게임의 재미와 경쟁의 짜릿함이라는 두 개의 장점을 결합하여.  보는 재미가 두배로 있고 캐주얼 및 하드코어 갬블러 모두에게 더 매력적으로 다가갑니다.또한 이스포츠는 기존 스포츠 보다 접근성이 좋습니다 장소에 관계없이 누구나 온라인으로 E스포츠 경기를 시청할수 있고  유행하는 개인방송 BJ , 게임유튜버 들의 게임에도 배팅이 가능합니다자유롭고, 접근성이 좋기 때문에 요즘에는 MZ세대 에게 인기가 많습니다. 그리고 E-SPORTS토토는 기존 스포츠토토 와 다르게 변수 가 많이 도출되며 결괏값또한 예상과 다르게 많이 나옵니다.이러한 부분을 주의하면서 배팅하는 것을 추천드리며 , 이 스포츠토토 배팅에서 승리를 위해서는 해당 게임을 직접 플레이하는것을 추천 드립니다. 어느 정도의 게임룰 과 게임 방법 대해 알고 있으면당연히 분석 또한 가능한 것으로 생각되며 자기가 배팅한 게임 에 대해 어느정도 승리 예측을 더 잘 할 수 있을거라고 생각이 됩니다.E스포츠 에 대해 좀더 관심을 가져서 트렌디한 배팅 과 재미를 함께 즐기셨으면 좋겠습니다.

@simulatorapk
26.10.2023 10:06 UTC
0

เว็บแทงบอลออนไลน์ ปัจจุบันมีผู้เปิดให้บริการ  มากมายตามแพลตฟอร์มต่างๆ เยอะจนทำให้คุณไม่รู้เลยว่าจะต้องเลือกใช้บริการ  กับเว็บไซต์หรือแพลตฟอร์มไหน และปัจจุบันมีข่าวไปในทางที่ไม่ดีกับวงการเว็บพนันออนไลน์ เว็บพนันโกงผู้เล่น จนทำให้หลายๆคนกลัว ไม่รู้เลยว่า เว็บแทงบอลออนไลน์เว็บไหนดี เว็บไหนมีความมั่นคง ปลอดภัย เว็บไหนเป็นเว็บเถื่อนที่เปิดให้บริการเพื่อหลอกลวงผู้เล่น บทความนี้จะมาบอกวิธีการสังเกต วิธีเลือกเว็บไซต์เป็นเว็บคู่ใจสำหรับแทงบอลออนไลน์ให้กับท่าน เว็บไซต์ที่เป็นเว็บตรง ลิขสิทธิ์แท้ มีมาตรฐานรองรับ มีความมั่นคง ปลอดภัย ในการทำธุรกรรมทางการเงิน การป้องกันข้อมูลส่วนบุคคล และด้านการให้บริการที่ดี ทันสมัย สอดคล้องกับยุคสมัยที่เปลี่ยนไป ถ้ามาแล้วมาเริ่มกันเลย

@simulatorapk
09.11.2023 21:44 UTC
0

스타토토사이트 대회 방식 또한 토너먼트제에서 풀리그 후 플레이오프 방식으로 바뀌었다.참가 팀 또한 16팀에서 10팀(2015 스프링 시즌에 한해 8팀)으로 줄어들었으며 단일 클럽 내 복수 팀의 참가가 불가능하게 바뀌었다.먼저 정규시즌 1위를 차지한 팀은 바로 결승전으로 직행하고 4위팀과 5위팀은 와일드카드 결정전을 통해 3위팀과의 준플레이오프를 치를 팀을 결정했다.그리고 준플레이오프에서 승리한 팀은 2위팀과의 플레이오프를 통해 결승 진출 여부를 결정지었다.반대로 정규시즌 6위부터 8위팀까지는 포스트시즌 진출에 실패하더라도 챔피언스 코리아에 잔류하며 챔피언스 9·10위팀은 리그 오브 레전드 챌린저스 코리아 우승·준우승팀과의 승강결정전(승강전)을 통해 참가팀 교체 여부를 결정짓는 방식으로 진행되었다.

@simulatorapk
14.11.2023 18:34 UTC
0

스타크래프트, 스타크래프트2, 리그 오브 레전드. 카드라이더, 카운터 스타리크, 서든어택, 스페셜포스, 스페셜 포스2,  피파 온라인, 도타 2, 히어로즈 오브 더 스톰, 하스스톤,오버워치, 배틀 그라운드등이 있습니다 . 이러한 게임들 마다  대회이름,  우승 트로피, 우승 상금 등이 다 다르며 경기에 필요한 인원 승리 조건 등도 다양합니다. 또한 E-스포츠 인기는 국내 뿐 아닌 전세계 적으로 매우 인기가많은 종목으로 2017년 기준 세계 적으로 3억 8천만 명 이상이 인터넷 , TV , 현장 에서 관람하고 있습니다 . 이러한 내용으로만 봐도 많은 사람들의 관심이 있는 종목 이라는것을 알수 있으며관심도가 매우 많은 종목이라는 것을 알수가 있습니다. 롤실시간사이트

@simulatorapk
22.01.2024 07:50 UTC
0

สมัคร เว็บยูฟ่า แทงบอลออนไลน์ สมัครแทงบอล ยูฟ่าเบท เว็บไซต์ที่ได้รับการยอมรับว่าเป็นหนึ่งในเว็บที่มีมาตรฐานสูงที่สุดในประเทศไทย แต่สิ่งที่ทำให้การ สมัครแทงบอลออนไลน์ เว็บแทงบอล ผ่านทางเราโดดเด่นยิ่งกว่าคือความสะดวกสบายและการบริการที่ยอดเยี่ยม เว็บตรงufabet มีการออกแบบที่ใช้งานง่าย มีระบบที่ปลอดภัยทั้งในการทำธุรกรรมและการเก็บข้อมูลส่วนตัวของผู้ใช้ ยูฟ่าเบทเว็บตรง เรามีทีมงานมืออาชีพพร้อมให้บริการและให้คำปรึกษาทุกขั้นตอนตลอดเวลาไม่มีวันหยุด แทงบอลออนไลน์

เมื่อ สมัครufa  ผ่านทางเรา คุณไม่เพียงแต่จะได้สัมผัสกับเกมและกิจกรรมที่หลากหลาย เว็บบอลยูฟ่า ยังมีโปรโมชันและข้อเสนอพิเศษที่ไม่สามารถหาได้จากที่อื่น สมัครเว็บยูฟ่า ทำให้การเล่นเป็นเรื่องที่สนุกสนานและคุ้มค่ามากขึ้น รับรองว่าการ สมัครufabetเว็บตรง เว็บบอลออนไลน์ เว็บตรงไม่ผ่านเอย่นต์ จะเป็นประสบการณ์ที่ทำให้คุณรู้สึกพอใจและต้องการกลับมาใช้บริการซ้ำอย่างแน่นอน