Блог пользователя Gornak40

Автор Gornak40, история, 20 месяцев назад, По-русски

Компилятор GCC предоставляет возможность использовать ассемблерные вставки. Это может быть полезно например для умножения двух 64-битных чисел по 64-битному модулю.

Дело в том, что умножая два 64-битных регистра, процессор сохраняет результат в паре регистров rdx (верхнюю часть) и rax (нижнюю часть). Деление же работает похожим образом: делимое берется с регистров rdx и rax, после чего в rax сохраняется частное, а в rdx остаток.

Используя эти знания можно реализовать аналог следующей функции:

inline long long mul(long long a, long long b) {
	return (__int128)a * b % 1000000014018503;
}

Вот таким образом:

inline long long mul(long long a, long long b) {
	long long res;
	asm(
		"mov %1, %%rax\n"
		"mov %2, %%rbx\n"
		"imul %%rbx\n"
		"mov $1000000014018503, %%rbx\n"
		"idiv %%rbx\n"
		"mov %%rdx, %0\n"
		:"=res"(res)
		:"a"(a), "b"(b)
	);
	return res;
}

Мы указываем на использование переменных res на запись, a и b на чтение. Они соответственно получают обозначения %0, %1, %2. Операции записываются с использованием стандартного AT&T синтаксиса.

Теперь вы умеете писать хеши по 64-битному модулю, что эквивалентно использованию пары по 32-битному модулю, без использования __int128.

  • Проголосовать: нравится
  • +42
  • Проголосовать: не нравится

»
20 месяцев назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

Автокомментарий: текст был обновлен пользователем Gornak40 (предыдущая версия, новая версия, сравнить).

»
20 месяцев назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

Auto comment: topic has been updated by Gornak40 (previous revision, new revision, compare).

»
20 месяцев назад, # |
  Проголосовать: нравится +32 Проголосовать: не нравится

You haven't declared any of the fixed registers you clobber with this code, so it's terrible undefined behavior: If the compiler was using rax for anything you are toast. Also, 64-bit idiv is very slow on some systems: You may find a floating-point-based method much faster. (And for hashing applications you can probably use Montgomery reduction instead of "ordinary" modmul for even better performance.)

»
20 месяцев назад, # |
Rev. 3   Проголосовать: нравится +16 Проголосовать: не нравится

I see a few problems with this code:

  • Codeforces runs on Windows, so rbx should be preserved (source), otherwise it may cause troubles when combined with GCC-generated code.
  • The assembly causes many things to be moved around often if you see the compiled code, making it inefficient.
    • If you want to write an entire function in asm, I suggest using GCC's __attribute__((naked)) (source).
  • Integer division instructions are very slow, and dividing by a constant can be optimized a lot. You can find many resources for fast division on Codeforces (like this blog or this).

That being said, using x86 instructions directly is significantly faster than running __int128 division (which calls a large, slower function __modti3) when you only need 64 bits of modulus and output.

Here is my version of the function in assembly (Windows call convention):

__attribute__((naked)) long long modmul(long long, long long, long long) {
    asm(R"(
        mov %rcx, %rax
        imul %rdx
        idiv %r8
        mov %rdx, %rax
        ret
    )");
}
for sysv users
»
20 месяцев назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

Codeforces has the gym named "Fast modular multiplication", where I have tested how fast the assembler insertion is.

Assembler insertion ~1326 ms
unsigned __int128 multiplication ~1482 ms

So, the assembler insertion is slightly faster, but not significantly faster.

  • »
    »
    20 месяцев назад, # ^ |
      Проголосовать: нравится 0 Проголосовать: не нравится

    It's also possible to implement inline assembly version without any function call overhead:

    inline uint64_t mulmod64(uint64_t a, uint64_t b, uint64_t c) {
    	uint64_t res;
    	asm(
    		"mul %2\n"
    		"div %3\n"
    		:"=&d"(res), "+a"(a)
    		:"r"(b), "r"(c)
    		:"cc"
    	);
    	return res;
    }
    

    But almost all CPU cycles are spent on executing the super slow division instruction in all code variants. The __int128 variant without inline assembly is also using the same division instruction (after some extra checks to ensure that division overflows won't be triggered).

    • »
      »
      »
      20 месяцев назад, # ^ |
        Проголосовать: нравится 0 Проголосовать: не нравится

      Yes, and it is faster, ~1248 ms. And your function can be inlined (this removes one jmp and two mov) despite the same assembler output (with exactness up to swapping commands' order and ud2 opcode) (on Linux): https://godbolt.org/z/r5MeMxdbM

      On Windows your function produces one extra mov, but inlining removes one jmp and one mov.

      Dump (on Windows)
»
14 месяцев назад, # |
  Проголосовать: нравится +3 Проголосовать: не нравится

If you really need int64 multiplication, better consider this variant:

using uint64 = unsigned long long;
uint64 modmul(uint64 a, uint64 b, uint64 M) {
	ll ret = a * b - M * uint64(1.L / M * a * b);
	return ret + M * (ret < 0) - M * (ret >= (ll)M);
}

There is a proof that it works here: https://github.com/kth-competitive-programming/kactl/blob/main/doc/modmul-proof.pdf

This is much faster, and is not correct only for some int64 values, that are almost certainly much bigger than any modulo you choose