كتبت هذين الحلين لـ Project Euler Q14 ، in Assembly and C++. هم نفس نهج القوة الغاشمة متطابقة لاختبار التخمين Collatz . تم تجميع حل الجمعية مع
nasm -felf64 p14.asm && gcc p14.o -o p14
تم تجميع C++ مع
g++ p14.cpp -o p14
التجميع ، p14.asm
section .data
fmt db "%d", 10, 0
global main
extern printf
section .text
main:
mov rcx, 1000000
xor rdi, rdi ; max i
xor rsi, rsi ; i
l1:
dec rcx
xor r10, r10 ; count
mov rax, rcx
l2:
test rax, 1
jpe even
mov rbx, 3
mul rbx
inc rax
jmp c1
even:
mov rbx, 2
xor rdx, rdx
div rbx
c1:
inc r10
cmp rax, 1
jne l2
cmp rdi, r10
cmovl rdi, r10
cmovl rsi, rcx
cmp rcx, 2
jne l1
mov rdi, fmt
xor rax, rax
call printf
ret
C++ ، p14.cpp
#include <iostream>
using namespace std;
int sequence(long n) {
int count = 1;
while (n != 1) {
if (n % 2 == 0)
n /= 2;
else
n = n*3 + 1;
++count;
}
return count;
}
int main() {
int max = 0, maxi;
for (int i = 999999; i > 0; --i) {
int s = sequence(i);
if (s > max) {
max = s;
maxi = i;
}
}
cout << maxi << endl;
}
أعرف معلومات عن تحسينات برنامج التحويل البرمجي لتحسين السرعة وكل شيء ، لكنني لا أرى العديد من الطرق لتحسين حل التجميع الخاص بي بشكل أكبر (تحدث برمجيًا وليس رياضيا).
يحتوي رمز C++ على معامل لكل مصطلح وقسم لكل فصل ، حيث التجميع هو تقسيم واحد فقط لكل فصل.
لكن الجمعية تأخذ في المتوسط 1 ثانية أطول من حل C++. لماذا هذا؟ أنا أسأل بدافع الفضول.
نظامي: 64 بت لينكس على 1.4 جيجا هرتز Intel Celeron 2955U (Haswell microarchitecture).
g++
(غير محسّن): متوسط 1272 مللي ثانية
g++ -O3
متوسط 578 مللي ثانية
asm (div) الأصلي متوسط 2650 مللي ثانية
Asm (shr)
avg 679 ms
johnfound asm ، يتم تجميعها بواسطة nasm avg 501 ms
hidefromkgb asm avg 200 ms
hidefromkgb asm محسّن بواسطةPeter Cordes avg 145 ms
@ Veedrac C++ avg 81 ms with -O3
، 305 ms with -O0
الادعاء بأن برنامج التحويل البرمجي C++ يمكنه إنتاج كود أفضل من مبرمج لغة التجميع المختص يعد خطأً سيئًا للغاية. وخاصة في هذه الحالة. يمكن للبشر دائمًا أن يجعل الكود أفضل من المحول البرمجي ، وهذا الموقف بالذات هو توضيح جيد لهذه المطالبة.
اختلاف التوقيت الذي تراه هو أن رمز التجميع في السؤال بعيد جدًا عن الأمثل في الحلقات الداخلية.
(الكود أدناه هو 32 بت ، ولكن يمكن تحويله بسهولة إلى 64 بت)
على سبيل المثال ، يمكن تحسين وظيفة التسلسل إلى 5 تعليمات فقط:
.seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1
يشبه الرمز بالكامل:
include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"
start:
InitializeAll
mov ecx, 999999
xor edi, edi ; max
xor ebx, ebx ; max i
.main_loop:
xor esi, esi
mov eax, ecx
.seq:
inc esi ; counter
lea edx, [3*eax+1] ; edx = 3*n+1
shr eax, 1 ; eax = n/2
cmovc eax, edx ; if CF eax = edx
jnz .seq ; jmp if n<>1
cmp edi, esi
cmovb edi, esi
cmovb ebx, ecx
dec ecx
jnz .main_loop
OutputValue "Max sequence: ", edi, 10, -1
OutputValue "Max index: ", ebx, 10, -1
FinalizeAll
stdcall TerminateAll, 0
من أجل ترجمة هذا الرمز ، FreshLib هو مطلوب.
في الاختبارات التي أجريتها (معالج AMD A4-1200 بسرعة 1 جيجاهرتز) ، يكون الرمز أعلاه أسرع أربع مرات تقريبًا من رمز C++ من السؤال (عند التحويل البرمجي باستخدام -O0
: 430 مللي ثانية مقابل 1900 مللي ثانية) ، وأسرع أكثر من مرتين ( 430 مللي ثانية مقابل 830 مللي ثانية) عند ترجمة رمز C++ مع -O3
.
إخراج كلا البرنامجين هو نفسه: أقصى تسلسل = 525 على i = 837799.
لمزيد من الأداء: يلاحظ تغيير بسيط أنه بعد n = 3n + 1 ، ستكون n متساوية ، بحيث يمكنك القسمة على 2 على الفور. ولن تكون 1 ، لذلك لن تحتاج إلى اختبارها. لذلك يمكنك توفير عدد قليل من البيانات إذا والكتابة:
while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
n = (3*n + 1) / 2;
if (n % 2 == 0) {
do n /= 2; while (n % 2 == 0);
if (n == 1) break;
}
}
إليك a big win: إذا نظرت إلى أدنى 8 بتات من n ، فكل الخطوات إلى أن تنقسم على 2 ثماني مرات يتم تحديدها بالكامل بواسطة تلك البتات الثمانية. على سبيل المثال ، إذا كانت آخر ثمانية بتات هي 0x01 ، فهذا يعني أن الرقم ثنائي هو ؟؟؟ 0000 0001 ثم الخطوات التالية هي:
3n+1 -> ???? 0000 0100
/ 2 -> ???? ?000 0010
/ 2 -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2 -> ???? ???0 0010
/ 2 -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2 -> ???? ???? ?010
/ 2 -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2 -> ???? ???? ???0
/ 2 -> ???? ???? ????
لذلك يمكن توقع كل هذه الخطوات ، واستبدال 256 كيلو + 1 بـ 81 كيلو + 1. سيحدث شيء مشابه لجميع المجموعات. حتى تتمكن من إنشاء حلقة مع بيان التبديل الكبير:
k = n / 256;
m = n % 256;
switch (m) {
case 0: n = 1 * k + 0; break;
case 1: n = 81 * k + 1; break;
case 2: n = 81 * k + 1; break;
...
case 155: n = 729 * k + 425; break;
...
}
قم بتشغيل الحلقة حتى n ≤ 128 ، لأنه في هذه المرحلة يمكن أن تصبح n 1 مع أقل من ثمانية أقسام بحلول 2 ، والقيام بثماني خطوات أو أكثر في وقت سيجعلك تفوت النقطة حيث تصل إلى 1 للمرة الأولى. ثم تابع الحلقة "العادية" - أو قم بإعداد جدول يخبرك بعدد الخطوات الأخرى اللازمة للوصول إلى 1.
PS. أظن بشدة أن اقتراح بيتر كوردس سيجعله أسرع. لن يكون هناك أي فروع مشروطة على الإطلاق ما عدا واحد ، وسيتم التنبؤ بذلك بشكل صحيح إلا عندما تنتهي الحلقة فعليًا. لذلك سيكون رمز شيء من هذا القبيل
static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }
while (n > 128) {
size_t lastBits = n % 256;
n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}
في الممارسة العملية ، يمكنك قياس ما إذا كانت معالجة آخر 9 و 10 و 11 و 12 بت من n في وقت واحد ستكون أسرع. بالنسبة لكل بت ، سيتضاعف عدد الإدخالات في الجدول ، وأتوقع حدوث تباطؤ عندما لا تنسجم الجداول مع ذاكرة التخزين المؤقت L1 بعد الآن.
PPS. إذا كنت بحاجة إلى عدد من العمليات: في كل تكرار ننفذ بالضبط ثمانية أقسام على قسمين ، وعدد متغير من العمليات (3n + 1) ، لذلك فإن الطريقة الواضحة لحساب العمليات ستكون مجموعة أخرى. ولكن يمكننا بالفعل حساب عدد الخطوات (بناءً على عدد التكرارات للحلقة).
يمكننا إعادة تعريف المشكلة بشكل طفيف: استبدل n بـ (3n + 1)/2 إذا كانت غريبة ، واستبدل n بـ n/2 حتى لو كان. بعد ذلك ، ستنفذ كل عملية تكرار 8 خطوات بالضبط ، لكن يمكنك التفكير في أن الغش :-) لذلك افترض أنه كانت هناك عمليات r n <- 3n + 1 وعمليات s n <- n/2. ستكون النتيجة تمامًا n '= n * 3 ^ r/2 ^ s ، لأن n <- 3n + 1 تعني n <- 3n * (1 + 1/3n). بأخذ اللوغاريتم نجد r = (s + log2 (n '/ n))/log2 (3).
إذا قمنا بالحلقة حتى n ≤ 1،000،000 ولدينا جدول تم حسابه مسبقًا عدد التكرارات المطلوبة من أي نقطة بداية n ≤ 1،000،000 ، فإن حساب r كما هو موضح أعلاه ، مقربًا إلى أقرب عدد صحيح ، سيعطي النتيجة الصحيحة ما لم تكن s كبيرة حقًا.
في ملاحظة غير ذات صلة إلى حد ما: المزيد من الخارقة الأداء!
عند اجتياز التسلسل ، يمكننا فقط الحصول على 3 حالات ممكنة في الحي المجاور للعنصر الحالي N
(معروض أولاً):
للقفز بعد هذين العنصرين يعني حساب (N >> 1) + N + 1
و ((N << 1) + N + 1) >> 1
و N >> 2
، على التوالي.
دعنا نثبت أنه في كلتا الحالتين (1) و (2) من الممكن استخدام الصيغة الأولى ، (N >> 1) + N + 1
.
الحالة (1) واضحة. تشير الحالة (2) إلى (N & 1) == 1
، لذلك إذا افترضنا (بدون فقدان العمومية) أن N طولها 2 بت وأن وحداتها ba
من الأكثر إلى الأقل أهمية ، ثم a = 1
، والحالات التالية:
(N << 1) + N + 1: (N >> 1) + N + 1:
b10 b1
b1 b
+ 1 + 1
---- ---
bBb0 bBb
حيث B = !b
. التحول الصحيح النتيجة الأولى يعطينا بالضبط ما نريد.
Q.E.D .: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1
.
كما ثبت ، يمكننا اجتياز عناصر التسلسل 2 في وقت واحد ، وذلك باستخدام عملية ثلاثية واحدة. آخر 2 × الحد من الوقت.
تبدو الخوارزمية الناتجة كما يلي:
uint64_t sequence(uint64_t size, uint64_t *path) {
uint64_t n, i, c, maxi = 0, maxc = 0;
for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
c = 2;
while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
c += 2;
if (n == 2)
c++;
if (c > maxc) {
maxi = i;
maxc = c;
}
}
*path = maxc;
return maxi;
}
int main() {
uint64_t maxi, maxc;
maxi = sequence(1000000, &maxc);
printf("%llu, %llu\n", maxi, maxc);
return 0;
}
نحن هنا نقارن n > 2
لأن العملية قد تتوقف عند 2 بدلاً من 1 إذا كان الطول الإجمالي للتسلسل غريبًا.
دعنا نترجم هذا إلى تجميع!
MOV RCX, 1000000;
DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;
@main:
XOR RSI, RSI;
LEA RDI, [RCX + 1];
@loop:
ADD RSI, 2;
LEA RDX, [RDI + RDI*2 + 2];
SHR RDX, 1;
SHRD RDI, RDI, 2; ror rdi,2 would do the same thing
CMOVL RDI, RDX; Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
CMOVS RDI, RDX;
CMP RDI, 2;
JA @loop;
LEA RDX, [RSI + 1];
CMOVE RSI, RDX;
CMP RAX, RSI;
CMOVB RAX, RSI;
CMOVB RBX, RCX;
SUB RCX, 2;
JA @main;
MOV RDI, RCX;
ADD RCX, 10;
Push RDI;
Push RCX;
@itoa:
XOR RDX, RDX;
DIV RCX;
ADD RDX, '0';
Push RDX;
TEST RAX, RAX;
JNE @itoa;
Push RCX;
LEA RAX, [RBX + 1];
TEST RBX, RBX;
MOV RBX, RDI;
JNE @itoa;
POP RCX;
INC RDI;
MOV RDX, RDI;
@outp:
MOV RSI, RSP;
MOV RAX, RDI;
SYSCALL;
POP RAX;
TEST RAX, RAX;
JNE @outp;
LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;
استخدم هذه الأوامر لتجميع:
nasm -f elf64 file.asm
ld -o file file.o
رؤية C ونسخة محسنة/من إصلاح bugfixed من قبل بيتر كوردس على Godbolt . (ملاحظة المحرر: نأسف لوضع إجابتي في إجابتك ، لكن إجابتي بلغت الحد المسموح به وهو 30 كيلو بايت من روابط Godbolt + text!)
تتم ترجمة برامج C++ إلى برامج التجميع أثناء إنشاء رمز الجهاز من التعليمات البرمجية المصدر. سيكون من الخطأ تقريبًا القول بأن التجميع أبطأ من C++. علاوة على ذلك ، يختلف الرمز الثنائي الذي تم إنشاؤه من برنامج التحويل البرمجي إلى برنامج التحويل البرمجي. لذلك فإن برنامج التحويل البرمجي C++ الذكي قد ينتج كودًا ثنائياً أكثر أمثل وفعالية من كود المجمّع البكم.
ومع ذلك أعتقد أن منهجية التنميط لديك بها عيوب معينة. فيما يلي إرشادات عامة للتوصيف:
لمشكلة Collatz ، يمكنك الحصول على دفعة كبيرة في الأداء من خلال التخزين المؤقت "ذيول". هذا هو الوقت/الذاكرة المفاضلة. راجع: التحفيظ ( https://en.wikipedia.org/wiki/Memoization ). يمكنك أيضًا البحث في حلول البرمجة الديناميكية لمفاضلات الوقت/الذاكرة الأخرى.
مثال تنفيذ الثعبان:
import sys
inner_loop = 0
def collatz_sequence(N, cache):
global inner_loop
l = [ ]
stop = False
n = N
tails = [ ]
while not stop:
inner_loop += 1
tmp = n
l.append(n)
if n <= 1:
stop = True
Elif n in cache:
stop = True
Elif n % 2:
n = 3*n + 1
else:
n = n // 2
tails.append((tmp, len(l)))
for key, offset in tails:
if not key in cache:
cache[key] = l[offset:]
return l
def gen_sequence(l, cache):
for elem in l:
yield elem
if elem in cache:
yield from gen_sequence(cache[elem], cache)
raise StopIteration
if __== "__main__":
le_cache = {}
for n in range(1, 4711, 5):
l = collatz_sequence(n, le_cache)
print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))
print("inner_loop = {}".format(inner_loop))
حتى بدون النظر إلى التجميع ، فإن السبب الأكثر وضوحًا هو أنه ربما تم تحسين /= 2
كـ >>=1
والعديد من المعالجات لديها عملية تحويل سريعة للغاية. ولكن حتى لو لم يكن لدى المعالج عملية تحويل ، فإن عدد صحيح هو أسرع من تقسيم النقطة العائمة.
تحرير: قد يختلف الأميال الخاص بك على عبارة "تقسيم عدد صحيح أسرع من تقسيم الفاصلة العائمة" أعلاه. تكشف التعليقات أدناه أن المعالجات الحديثة أعطت الأولوية لتحسين تقسيم fp على عدد صحيح. لذلك إذا كان شخص ما يبحث عن السبب الأكثر ترجيحًا للتسريع الذي يطرحه سؤال مؤشر الترابط هذا ، فإن تحسين المحول البرمجي /=2
كـ >>=1
سيكون أفضل مكان للبحث عنه.
في ملاحظة غير مرتبطة ، إذا كان n
غريبًا ، فسيكون التعبير n*3+1
دائمًا متساويًا. لذلك ليست هناك حاجة للتحقق. يمكنك تغيير هذا الفرع ل
{
n = (n*3+1) >> 1;
count += 2;
}
وبالتالي فإن البيان كله يكون بعد ذلك
if (n & 1)
{
n = (n*3 + 1) >> 1;
count += 2;
}
else
{
n >>= 1;
++count;
}
من التعليقات:
ولكن ، لا يتوقف هذا الرمز (بسبب تجاوز عدد صحيح)!؟! إيف داوست
بالنسبة إلى العديد من الأرقام ، سوف لا تجاوز السعة.
إذا كان will overflow - لأحد هذه البذور الأولية غير المحظوظة ، فمن المحتمل جدًا أن يتلاقى عدد الفائض في اتجاه واحد دون تجاوز آخر.
لا يزال هذا يطرح سؤالًا مثيرًا للاهتمام ، هل هناك عدد من البذور الزائدة الدائرية؟
أي سلسلة نهائية متقاربة بسيطة تبدأ بقوة ذات قيمة (واضحة بما فيه الكفاية؟).
2 ^ 64 سيتجاوز إلى صفر ، وهو حلقة لانهائية غير محددة وفقًا للخوارزمية (تنتهي فقط بـ 1) ، ولكن الحل الأمثل في الإجابة سينتهي بسبب shr rax
إنتاج ZF = 1.
يمكن أن ننتج 2 ^ 64؟ إذا كان رقم البداية هو 0x5555555555555555
، فهذا رقم فردي ، والرقم التالي هو 3n + 1 ، وهو 0xFFFFFFFFFFFFFFFF + 1
= 0
. من الناحية النظرية في حالة غير محددة من الخوارزمية ، ولكن سيتم استرداد إجابة johnfound المحسنة من خلال الخروج على ZF = 1. cmp rax,1
لـ Peter Cordes ستنتهي بحلقة لانهائية (QED البديل 1 ، "cheapo" من خلال رقم 0
غير محدد).
ماذا عن بعض الأرقام الأكثر تعقيدًا ، والتي ستخلق دورة بدون 0
؟ بصراحة ، لست متأكدًا ، نظرية نظري في الرياضيات ضبابية للغاية بحيث لا يمكنني الحصول على أي فكرة جادة ، وكيفية التعامل معها بطريقة جادة. لكن بشكل حدسي ، أود أن أقول إن السلسلة ستتقارب إلى 1 لكل رقم: 0 <number ، حيث إن الصيغة 3n + 1 ستحول ببطء كل عامل أولي غير 2 للرقم الأصلي (أو الوسيط) إلى بعض القوة 2 ، عاجلاً أم آجلاً . لذلك لا داعي للقلق بشأن الحلقة اللانهائية للمسلسل الأصلي ، فالتجاوز فقط هو الذي يمكن أن يعيقنا.
لذلك أنا فقط وضعت أرقام قليلة في ورقة وألقيت نظرة على أرقام مبتورة 8 بت.
هناك ثلاث قيم تفيض إلى 0
: 227
و 170
و 85
(85
انتقل مباشرة إلى 0
، واثنان آخران يتجهان نحو 85
).
ولكن ليس هناك قيمة لإنشاء البذور الفائضة الدورية.
من الممتع أن أكون قد قمت بالتحقق ، وهو أول رقم يعاني من اقتطاع 8 بت ، ويتأثر بالفعل 27
! يصل إلى القيمة 9232
في السلسلة المناسبة غير المقطوعة (القيمة المقطوعة الأولى هي 322
في الخطوة الثانية عشرة) ، والحد الأقصى للقيمة التي تم التوصل إليها لأي من أرقام الإدخال 2-255 بطريقة غير مبتورة هي 13120
(لـ 255
نفسها) ، أقصى عدد من الخطوات للالتقاء إلى 1
حوالي 128
(+ -2 ، لست متأكدًا مما إذا كانت "1" ستحسب ، إلخ ...).
من المثير للاهتمام (بالنسبة لي) أن الرقم 9232
هو الحد الأقصى للعديد من أرقام المصدر الأخرى ، ما هو المميز في ذلك؟ : -O 9232
= 0x2410
... hmmm .. لا فكرة.
لسوء الحظ ، لا يمكنني الحصول على أي فهم عميق لهذه السلسلة ، ولماذا تتلاقى وما هي آثار اقتطاعها إلى k بت ، لكن مع وضع cmp number,1
، من الممكن بالتأكيد وضع الخوارزمية في حلقة لا نهائية مع قيمة إدخال معينة تنتهي كـ 0
بعد الاقتطاع.
لكن القيمة 27
التي تفيض لحالة 8 بت هي نوع من التنبيه ، يبدو أن هذا إذا كنت تحسب عدد الخطوات للوصول إلى القيمة 1
، فسوف تحصل على نتيجة خاطئة لغالبية الأرقام من مجموعة الأعداد الصحيحة k-bit. بالنسبة لعدد صحيح من 8 بتات ، فإن 146 رقمًا من أصل 256 قد أثرت على السلسلة بالاقتطاع (ربما لا يزال بعضها يصل إلى العدد الصحيح من الخطوات عن طريق الصدفة ربما ، أنا كسول جدًا للتحقق من ذلك).
لم تنشر الكود الذي تم إنشاؤه بواسطة المترجم ، لذلك هناك بعض التخمينات هنا ، ولكن حتى بدون رؤيته ، يمكن للمرء أن يقول ما يلي:
test rax, 1
jpe even
... لديه فرصة بنسبة 50٪ لإساءة تقدير الفرع ، وسيكون ذلك مكلفًا.
من شبه المؤكد أن المحول البرمجي يقوم بإجراء كلا الحسابين (الذي يكلف بشكل أكثر تقلبًا نظرًا لأن div/mod عبارة عن زمن انتقال طويل للغاية ، وبالتالي فإن الضرب الإضافي "مجاني") ويتابع مع CMOV. التي ، بطبيعة الحال ، لديها صفر في المئة فرصة لسوء تقدير.
كإجابة عامة ، غير موجهة على وجه التحديد إلى هذه المهمة: في كثير من الحالات ، يمكنك تسريع أي برنامج بشكل كبير عن طريق إجراء تحسينات على مستوى عال. مثل حساب البيانات مرة واحدة بدلاً من عدة مرات ، وتجنب العمل غير الضروري تمامًا ، واستخدام ذاكرات التخزين المؤقت بأفضل طريقة ، وما إلى ذلك. هذه الأشياء أسهل بكثير في القيام بلغة عالية المستوى.
كتابة رمز المجمّع ، هو ممكن لتحسين ما يفعله المحول البرمجي الأمثل ، لكنه عمل شاق. وبمجرد الانتهاء من ذلك ، يصبح تعديل التعليمات البرمجية لديك أكثر صعوبة ، لذلك يصعب عليك إضافة تحسينات حسابية. في بعض الأحيان يكون للمعالج وظيفة لا يمكنك استخدامها من لغة عالية المستوى ، وغالبًا ما يكون التجميع المضمّن مفيدًا في هذه الحالات ولا يزال يتيح لك استخدام لغة عالية المستوى.
في مشكلات Euler ، تنجح معظم الوقت في بناء شيء ما ، وإيجاد سبب بطئ ، وبناء شيء أفضل ، وإيجاد سبب بطئه ، وما إلى ذلك. هذا صعب للغاية باستخدام المجمّع. عادة ما تفوق الخوارزمية الأفضل بنصف السرعة الممكنة على الخوارزمية الأسوأ بأقصى سرعة ، والحصول على السرعة الكاملة في المجمع ليس تافهاً.