ทำไม strlen ของ glibc จึงจำเป็นต้องมีความซับซ้อนในการทำงานอย่างรวดเร็ว


286

ฉันดูstrlenรหัสที่นี่และสงสัยว่าการเพิ่มประสิทธิภาพที่ใช้ในรหัสนั้นจำเป็นจริง ๆ หรือไม่ ตัวอย่างเช่นทำไมบางอย่างจะไม่เหมือนงานต่อไปนี้ดีหรือดีกว่า

unsigned long strlen(char s[]) {
    unsigned long i;
    for (i = 0; s[i] != '\0'; i++)
        continue;
    return i;
}

รหัสเรียบง่ายกว่าและดีกว่าสำหรับการรวบรวมเพื่อเพิ่มประสิทธิภาพหรือไม่

โค้ดของstrlenหน้าหลังลิงค์มีลักษณะดังนี้:

/* Copyright (C) 1991, 1993, 1997, 2000, 2003 Free Software Foundation, Inc.
   This file is part of the GNU C Library.
   Written by Torbjorn Granlund (tege@sics.se),
   with help from Dan Sahlin (dan@sics.se);
   commentary by Jim Blandy (jimb@ai.mit.edu).

   The GNU C Library is free software; you can redistribute it and/or
   modify it under the terms of the GNU Lesser General Public
   License as published by the Free Software Foundation; either
   version 2.1 of the License, or (at your option) any later version.

   The GNU C Library is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   Lesser General Public License for more details.

   You should have received a copy of the GNU Lesser General Public
   License along with the GNU C Library; if not, write to the Free
   Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
   02111-1307 USA.  */

#include <string.h>
#include <stdlib.h>

#undef strlen

/* Return the length of the null-terminated string STR.  Scan for
   the null terminator quickly by testing four bytes at a time.  */
size_t
strlen (str)
     const char *str;
{
  const char *char_ptr;
  const unsigned long int *longword_ptr;
  unsigned long int longword, magic_bits, himagic, lomagic;

  /* Handle the first few characters by reading one character at a time.
     Do this until CHAR_PTR is aligned on a longword boundary.  */
  for (char_ptr = str; ((unsigned long int) char_ptr
            & (sizeof (longword) - 1)) != 0;
       ++char_ptr)
    if (*char_ptr == '\0')
      return char_ptr - str;

  /* All these elucidatory comments refer to 4-byte longwords,
     but the theory applies equally well to 8-byte longwords.  */

  longword_ptr = (unsigned long int *) char_ptr;

  /* Bits 31, 24, 16, and 8 of this number are zero.  Call these bits
     the "holes."  Note that there is a hole just to the left of
     each byte, with an extra at the end:

     bits:  01111110 11111110 11111110 11111111
     bytes: AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD

     The 1-bits make sure that carries propagate to the next 0-bit.
     The 0-bits provide holes for carries to fall into.  */
  magic_bits = 0x7efefeffL;
  himagic = 0x80808080L;
  lomagic = 0x01010101L;
  if (sizeof (longword) > 4)
    {
      /* 64-bit version of the magic.  */
      /* Do the shift in two steps to avoid a warning if long has 32 bits.  */
      magic_bits = ((0x7efefefeL << 16) << 16) | 0xfefefeffL;
      himagic = ((himagic << 16) << 16) | himagic;
      lomagic = ((lomagic << 16) << 16) | lomagic;
    }
  if (sizeof (longword) > 8)
    abort ();

  /* Instead of the traditional loop which tests each character,
     we will test a longword at a time.  The tricky part is testing
     if *any of the four* bytes in the longword in question are zero.  */
  for (;;)
    {
      /* We tentatively exit the loop if adding MAGIC_BITS to
     LONGWORD fails to change any of the hole bits of LONGWORD.

     1) Is this safe?  Will it catch all the zero bytes?
     Suppose there is a byte with all zeros.  Any carry bits
     propagating from its left will fall into the hole at its
     least significant bit and stop.  Since there will be no
     carry from its most significant bit, the LSB of the
     byte to the left will be unchanged, and the zero will be
     detected.

     2) Is this worthwhile?  Will it ignore everything except
     zero bytes?  Suppose every byte of LONGWORD has a bit set
     somewhere.  There will be a carry into bit 8.  If bit 8
     is set, this will carry into bit 16.  If bit 8 is clear,
     one of bits 9-15 must be set, so there will be a carry
     into bit 16.  Similarly, there will be a carry into bit
     24.  If one of bits 24-30 is set, there will be a carry
     into bit 31, so all of the hole bits will be changed.

     The one misfire occurs when bits 24-30 are clear and bit
     31 is set; in this case, the hole at bit 31 is not
     changed.  If we had access to the processor carry flag,
     we could close this loophole by putting the fourth hole
     at bit 32!

     So it ignores everything except 128's, when they're aligned
     properly.  */

      longword = *longword_ptr++;

      if (
#if 0
      /* Add MAGIC_BITS to LONGWORD.  */
      (((longword + magic_bits)

        /* Set those bits that were unchanged by the addition.  */
        ^ ~longword)

       /* Look at only the hole bits.  If any of the hole bits
          are unchanged, most likely one of the bytes was a
          zero.  */
       & ~magic_bits)
#else
      ((longword - lomagic) & himagic)
#endif
      != 0)
    {
      /* Which of the bytes was the zero?  If none of them were, it was
         a misfire; continue the search.  */

      const char *cp = (const char *) (longword_ptr - 1);

      if (cp[0] == 0)
        return cp - str;
      if (cp[1] == 0)
        return cp - str + 1;
      if (cp[2] == 0)
        return cp - str + 2;
      if (cp[3] == 0)
        return cp - str + 3;
      if (sizeof (longword) > 4)
        {
          if (cp[4] == 0)
        return cp - str + 4;
          if (cp[5] == 0)
        return cp - str + 5;
          if (cp[6] == 0)
        return cp - str + 6;
          if (cp[7] == 0)
        return cp - str + 7;
        }
    }
    }
}
libc_hidden_builtin_def (strlen)

ทำไมรุ่นนี้จึงทำงานได้อย่างรวดเร็ว

มันไม่ได้ทำงานที่ไม่จำเป็นมากมายหรอกเหรอ?


2
ความคิดเห็นไม่ได้มีไว้สำหรับการอภิปรายเพิ่มเติม การสนทนานี้ได้รับการย้ายไปแชท
Samuel Liew

18
สำหรับการอ้างอิงในอนาคตแหล่งเก็บข้อมูลอย่างเป็นทางการสำหรับ GNU libc อยู่ที่ < sourceware.org/git/?p=glibc.git > < sourceware.org/git/?p=glibc.git;a=blob;f=string/… > จะแสดงรหัสที่คล้ายกับที่กล่าวมาข้างต้น อย่างไรก็ตามการใช้ภาษาแอสเซมบลีที่เขียนด้วยลายมือจากsysdepsไดเรกทอรีจะถูกนำมาใช้แทนบนสถาปัตยกรรมที่สนับสนุนของ glibc ส่วนใหญ่ (สถาปัตยกรรมที่ใช้บ่อยที่สุดที่ไม่มีการทดแทนคือ MIPS)
zwol

9
การลงคะแนนเพื่อปิดนี้เป็นพื้นฐานความคิดเห็น; "xxx จำเป็นจริงๆใน xxx หรือไม่" เป็นความคิดเห็นของผู้คน
SS Anne

2
@ JL2210: จุดที่ดีแก้ไขชื่อเพื่อจับภาพจิตวิญญาณของคำถามในชื่อที่ดูเหมือนจะไม่สงสัยว่าจำเป็นต้องมีประสิทธิภาพหรือไม่ทำไมเราจึงต้องการการเพิ่มประสิทธิภาพเหล่านี้เพื่อให้ได้ประสิทธิภาพ
Peter Cordes

9
@ JL2210 FWIW ชื่อเดิมคือ "ทำไม strlen จึงซับซ้อนใน C [sic!]" และมันถูกปิดเป็น "กว้างเกินไป" จากนั้นเปิดใหม่แล้วปิดในฐานะ "ตามความคิดเห็นเป็นหลัก" ฉันพยายามที่จะแก้ไขปัญหานี้ (ในการข้าม "คุณทำลายคำถามของฉัน!" และ "พวกคุณกำลังใช้อำนาจการแก้ไขของคุณ!" ในขณะเดียวกัน) แต่ IMVHO ปัญหาโกหก (และยังคงอยู่) ในคำถามพื้นฐานของคำถาม ซึ่งเป็นปัญหา ("รหัสนี้ซับซ้อนเกินไปสำหรับฉันที่จะเข้าใจ" ไม่เหมาะสำหรับคำถามและคำตอบ - IMO มันเป็นคำขอสำหรับการติวไม่ใช่สำหรับคำตอบ) ฉันไม่ได้สัมผัสอีกครั้งด้วยเสา 60 ฟุต :)

คำตอบ:


233

คุณไม่ต้องการและคุณไม่ควรเขียนโค้ดแบบนั้นโดยเฉพาะถ้าคุณไม่ใช่ผู้ขาย C คอมไพเลอร์ / ไลบรารีมาตรฐาน มันเป็นรหัสที่ใช้เพื่อนำไปใช้strlenกับการแฮ็กและสมมติฐานความเร็วที่น่าสงสัยบางอย่าง (ซึ่งไม่ได้ทดสอบกับการยืนยันหรือกล่าวถึงในความคิดเห็น):

  • unsigned long เป็น 4 หรือ 8 ไบต์
  • ไบต์คือ 8 บิต
  • ตัวชี้สามารถถูกโยนไปunsigned long longและไม่uintptr_t
  • หนึ่งสามารถจัดตำแหน่งตัวชี้เพียงแค่ตรวจสอบว่าบิตลำดับต่ำสุด 2 หรือ 3 เป็นศูนย์
  • หนึ่งสามารถเข้าถึงสตริงเป็นunsigned longs
  • หนึ่งสามารถอ่านผ่านจุดสิ้นสุดของอาร์เรย์โดยไม่มีผลร้ายใด ๆ

ยิ่งไปกว่านั้นคอมไพเลอร์ที่ดียังสามารถแทนที่โค้ดที่เขียนเป็น

size_t stupid_strlen(const char s[]) {
    size_t i;
    for (i=0; s[i] != '\0'; i++)
        ;
    return i;
}

(โปรดสังเกตว่ามันจะต้องเป็นชนิดที่เข้ากันได้กับsize_t) กับรุ่นอินไลน์ของคอมไพเลอร์ในตัวstrlenหรือ vectorize รหัส; แต่คอมไพเลอร์จะไม่น่าจะสามารถเพิ่มประสิทธิภาพรุ่นที่ซับซ้อน


strlenฟังก์ชั่นการอธิบายโดยC11 7.24.6.3เป็น:

ลักษณะ

  1. strlenฟังก์ชั่นคำนวณความยาวของสตริงที่ชี้ไปโดย s

ผลตอบแทน

  1. strlenฟังก์ชันส่งกลับจำนวนตัวอักษรที่นำหน้าตัวอักษรยุติ null ที่

ตอนนี้ถ้าสตริงที่ชี้ไปยังsอยู่ในอาร์เรย์ของอักขระนานพอที่จะมีสตริงและ NUL ที่สิ้นสุดการทำงานจะไม่ได้กำหนดถ้าเราเข้าถึงสตริงที่ผ่านตัวยุติ null ตัวอย่างเช่น

char *str = "hello world";  // or
char array[] = "hello world";

ดังนั้นจริงๆเพียงวิธีการในมาตรฐาน / แบบพกพาอย่างเต็มที่สอดคล้อง C เพื่อดำเนินการนี้ได้อย่างถูกต้องเป็นวิธีที่มันถูกเขียนในของคุณคำถามยกเว้นสำหรับการเปลี่ยนแปลงเล็กน้อย - คุณสามารถหลอกว่าเป็นเร็วขึ้นโดยการคลี่ห่วง ฯลฯ แต่ก็ยังคงต้องทำหนึ่งไบต์ในเวลา

(ตามที่ผู้แสดงความคิดเห็นได้ชี้ให้เห็นเมื่อการพกพาที่เข้มงวดเป็นภาระมากเกินไปการใช้ประโยชน์จากสมมติฐานที่สมเหตุสมผลหรือเป็นที่รู้จักนั้นไม่ได้เลวร้ายเสมอไปโดยเฉพาะอย่างยิ่งในรหัสที่เป็นส่วนหนึ่งของการใช้งาน C อย่างใดอย่างหนึ่ง กฎก่อนที่จะรู้วิธี / เมื่อคุณสามารถโค้งงอพวกเขา.)


ที่เชื่อมโยงstrlenการดำเนินการตรวจสอบครั้งแรกไบต์เป็นรายบุคคลจนกว่าจะชี้เป็นชี้ไปที่ธรรมชาติ 4 หรือ 8 unsigned longไบต์จัดตำแหน่งเขตแดนของ มาตรฐาน C บอกว่าการเข้าถึงตัวชี้ที่ไม่ได้รับการจัดตำแหน่งอย่างเหมาะสมนั้นมีพฤติกรรมที่ไม่ได้กำหนดดังนั้นสิ่งนี้จะต้องทำเพื่อให้ลูกเล่นที่สกปรกต่อไปนั้นสกปรก (ในทางปฏิบัติเกี่ยวกับสถาปัตยกรรมซีพียูบางตัวที่ไม่ใช่ x86 คำที่ไม่ถูกต้องหรือโหลด doubleword จะเป็นความผิด C ไม่ใช่ภาษาแอสเซมบลีแบบพกพา แต่รหัสนี้ใช้วิธีนั้น) นอกจากนี้ยังเป็นสิ่งที่ทำให้สามารถอ่านจุดสิ้นสุดของวัตถุได้โดยไม่เสี่ยงต่อการเกิดข้อผิดพลาดในการใช้งานที่การป้องกันหน่วยความจำทำงานในบล็อกที่จัดเรียง (เช่นหน้าหน่วยความจำเสมือน 4kiB)

ตอนนี้ส่วนที่สกปรกมา: รหัสแบ่งสัญญาและอ่าน 4 หรือ 8 ไบต์ 8 บิตในเวลา (a long int) และใช้เคล็ดลับบิตด้วยการเพิ่มที่ไม่ได้ลงนามเพื่อคำนวณอย่างรวดเร็วหากมีศูนย์ใด ๆภายใน 4 หรือ 8 ไบต์ - มันใช้หมายเลขที่สร้างขึ้นเป็นพิเศษเพื่อที่จะทำให้บิตการดำเนินการเปลี่ยนบิตที่ถูกจับโดยหน้ากากบิต ในสาระสำคัญนี้จะคิดออกว่าใด ๆ ของ 4 หรือ 8 ไบต์ในหน้ากากเป็นศูนย์ควรจะเร็วกว่าการวนลูปผ่านแต่ละไบต์เหล่านี้ ในที่สุดก็มีการวนซ้ำในตอนท้ายเพื่อหาว่าไบต์ใดเป็นศูนย์แรกถ้ามีและส่งคืนผลลัพธ์

ปัญหาใหญ่ที่สุดคือว่าในsizeof (unsigned long) - 1ครั้งจากsizeof (unsigned long)กรณีก็จะอ่านที่ผ่านมาท้ายของสตริง - เพียงถ้าไบต์โมฆะอยู่ในสุดท้ายไบต์ Accessed (เช่นในน้อย endian มากที่สุดอย่างมีนัยสำคัญและใน big-อย่างมีนัยสำคัญน้อยที่สุด) มันไม่สามารถเข้าถึงอาร์เรย์นอกขอบเขตได้หรือไม่!


รหัสแม้ว่าจะใช้strlenในการใช้งานในไลบรารีมาตรฐาน C คือรหัสไม่ดี มันมีแง่มุมต่าง ๆ ที่กำหนดโดยการนำไปปฏิบัติและไม่ได้กำหนดไว้ในนั้นและไม่ควรใช้ที่ใดก็ได้แทนที่จะเป็นระบบที่จัดเตรียมไว้strlen- ฉันเปลี่ยนชื่อฟังก์ชั่นthe_strlenที่นี่และเพิ่มสิ่งต่อไปนี้main:

int main(void) {
    char buf[12];
    printf("%zu\n", the_strlen(fgets(buf, 12, stdin)));
}

บัฟเฟอร์มีการปรับขนาดอย่างระมัดระวังเพื่อให้สามารถเก็บhello worldสตริงและเทอร์มิเนเตอร์ได้อย่างแม่นยำ อย่างไรก็ตามในโปรเซสเซอร์ 64 บิตของฉันunsigned longคือ 8 ไบต์ดังนั้นการเข้าถึงส่วนหลังจะเกินบัฟเฟอร์นี้

ถ้าตอนนี้ผมรวบรวม-fsanitize=undefinedและ-fsanitize=addressและเรียกใช้โปรแกรมที่เกิดขึ้นผมจะได้รับ:

% ./a.out
hello world
=================================================================
==8355==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffffe63a3f8 at pc 0x55fbec46ab6c bp 0x7ffffe63a350 sp 0x7ffffe63a340
READ of size 8 at 0x7ffffe63a3f8 thread T0
    #0 0x55fbec46ab6b in the_strlen (.../a.out+0x1b6b)
    #1 0x55fbec46b139 in main (.../a.out+0x2139)
    #2 0x7f4f0848fb96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
    #3 0x55fbec46a949 in _start (.../a.out+0x1949)

Address 0x7ffffe63a3f8 is located in stack of thread T0 at offset 40 in frame
    #0 0x55fbec46b07c in main (.../a.out+0x207c)

  This frame has 1 object(s):
    [32, 44) 'buf' <== Memory access at offset 40 partially overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism or swapcontext
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (.../a.out+0x1b6b) in the_strlen
Shadow bytes around the buggy address:
  0x10007fcbf420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fcbf470: 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00[04]
  0x10007fcbf480: f2 f2 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==8355==ABORTING

คือสิ่งเลวร้ายเกิดขึ้น


120
Re: "แฮ็สงสัยมากความเร็วและการตั้งสมมติฐาน" - นั่นคือสงสัยมากในโค้ดแบบพกพา ไลบรารีมาตรฐานถูกเขียนขึ้นสำหรับคอมไพเลอร์ / ฮาร์ดแวร์เฉพาะโดยมีความรู้เกี่ยวกับพฤติกรรมที่แท้จริงของสิ่งต่าง ๆ ที่คำจำกัดความภาษาทิ้งไว้โดยไม่ได้กำหนด ใช่คนส่วนใหญ่ไม่ควรเขียนโค้ดแบบนั้น แต่ในบริบทของการใช้งานไลบรารี่มาตรฐานแบบพกพานั้นไม่เลวเลยทีเดียว
Pete Becker

4
เห็นด้วยอย่าเขียนสิ่งนี้ด้วยตัวคุณเอง หรือแทบไม่เคยเลย การเพิ่มประสิทธิภาพก่อนวัยอันควรเป็นแหล่งที่มาของความชั่วร้ายทั้งหมด (ในกรณีนี้อาจเป็นแรงจูงใจจริง ๆ ) หากคุณลงเอยด้วยการเรียกใช้ strlen () จำนวนมากบนสายอักขระที่ยาวมาก ๆ เดียวกันแอปพลิเคชันของคุณอาจถูกเขียนต่างออกไป คุณโอนย้ายเป็นตัวอย่างบันทึก stringlength ในตัวแปรแล้วเมื่อสร้างสตริงและไม่จำเป็นต้องเรียก strlen () เลย
ghellquist

65
@ghellquist: การเพิ่มประสิทธิภาพการเรียกใช้ไลบรารีที่ใช้บ่อย ๆ นั้นแทบจะไม่ "การปรับให้เหมาะสมก่อนกำหนด"
jamesqf

7
@Atti Haapala: ทำไมคุณคิดว่า strlen ควรเป็น O (1) และสิ่งที่เรามีที่นี่คือการใช้งานหลายอย่างซึ่งทั้งหมดคือ O (n) แต่มีตัวคูณคงที่ที่แตกต่างกัน คุณอาจไม่คิดว่าสำคัญ แต่สำหรับเราบางคนการใช้อัลกอริทึม O (n) ที่ทำงานในไมโครวินาทีนั้นดีกว่าที่ใช้เวลาไม่กี่วินาทีหรือแม้แต่มิลลิวินาทีเพราะมันอาจถูกเรียกว่าหลายพันล้านครั้งใน หลักสูตรของงาน
jamesqf

8
@PeteBecker: ไม่เพียงแค่นั้นในบริบทของไลบรารีมาตรฐาน (ไม่มากในกรณีนี้แม้ว่า) การเขียนรหัส nonportable สามารถเป็นบรรทัดฐานตามวัตถุประสงค์ของไลบรารีมาตรฐานคือการให้อินเทอร์เฟซมาตรฐานการใช้สิ่งเฉพาะ
PlasmaHH

148

มีการเดาผิดอย่างมาก (เล็กน้อยหรือทั้งหมด) ในความคิดเห็นเกี่ยวกับรายละเอียด / พื้นหลังสำหรับเรื่องนี้

คุณกำลังดูการใช้ C fallback ที่ปรับให้เหมาะสมที่สุดของ glibc (สำหรับอกหักที่ไม่ได้มีการดำเนินงานที่ asm ที่เขียนด้วยมือ) หรือรหัสรุ่นเก่าซึ่งยังคงอยู่ในแผนภูมิแหล่งที่มาของ glibc https://code.woboq.org/userspace/glibc/string/strlen.c.htmlเป็นโค้ดเบราว์เซอร์ที่อ้างอิงจากแผนผัง glibc git ปัจจุบัน เห็นได้ชัดว่ามันยังคงถูกใช้โดยเป้าหมาย glibc หลักบางประการรวมถึง MIPS (ขอบคุณ @zwol)

บน ISAs ยอดนิยมเช่น x86 และ ARM glibc ใช้ asm ที่เขียนด้วยมือ

ดังนั้นแรงจูงใจในการเปลี่ยนแปลงอะไรก็ตามเกี่ยวกับรหัสนี้จึงต่ำกว่าที่คุณคิด

รหัสความผิดพลาดนี้ ( https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord ) ไม่ใช่สิ่งที่ทำงานบนเซิร์ฟเวอร์ / เดสก์ท็อป / สมาร์ทโฟน / ของคุณ มันดีกว่าลูปที่ไร้เดียงสาต่อครั้ง แต่ความผิดพลาดนี้ค่อนข้างแย่เมื่อเทียบกับ asm ที่มีประสิทธิภาพสำหรับซีพียูสมัยใหม่ (โดยเฉพาะอย่างยิ่ง x86 ที่ AVX2 SIMD ช่วยให้ตรวจสอบ 32 ไบต์พร้อมคำแนะนำสองคู่ช่วยให้ 32 ถึง 64 ไบต์ต่อนาฬิกา วนรอบในลูปหลักหากข้อมูลร้อนในแคช L1d บนซีพียูสมัยใหม่ที่มีโหลดเวกเตอร์ 2 / clock และปริมาณงาน ALU เช่นสำหรับสตริงขนาดกลางที่โอเวอร์เฮดเริ่มต้นไม่ได้ทำงาน)

glibc ใช้เทคนิคการลิงก์แบบไดนามิกเพื่อแก้ไขstrlenเป็นเวอร์ชั่นที่ดีที่สุดสำหรับ CPU ของคุณดังนั้นแม้ใน x86 จะมีรุ่น SSE2 (เวกเตอร์ 16 ไบต์, พื้นฐานสำหรับ x86-64) และรุ่น AVX2 (เวกเตอร์ 32 ไบต์)

x86 มีการถ่ายโอนข้อมูลที่มีประสิทธิภาพระหว่างเวกเตอร์และการลงทะเบียนที่ใช้งานทั่วไปซึ่งทำให้ดีสำหรับการใช้ SIMD เพื่อเพิ่มความเร็วการทำงานของสตริงที่มีความยาวโดยปริยายซึ่งขึ้นอยู่กับการควบคุมข้อมูล pcmpeqb/ pmovmskbทำให้สามารถทดสอบ 16 ไบต์แยกกันในแต่ละครั้ง

glibc มีรุ่น AArch64 เช่นนั้นโดยใช้ AdvSIMDและรุ่นสำหรับ AArch64 ซีพียูที่ vector-> GP ลงทะเบียนแผงลอยไปป์ไลน์ดังนั้นจึงใช้ bithack นี้จริง แต่ใช้เลขศูนย์นำหน้าเพื่อค้นหาจำนวนไบต์ภายในรีจิสเตอร์เมื่อได้รับความนิยมและใช้ประโยชน์จากการเข้าใช้ที่ไม่เป็นศูนย์ของ AArch64 ที่มีประสิทธิภาพหลังจากตรวจสอบการข้ามหน้า

สิ่งที่เกี่ยวข้องด้วย: ทำไมรหัสนี้ 6.5x ช้าลงเมื่อเปิดใช้งานการเพิ่มประสิทธิภาพ มีรายละเอียดเพิ่มเติมเกี่ยวกับสิ่งที่เร็วและช้าใน x86 asm สำหรับstrlenบัฟเฟอร์ขนาดใหญ่และการใช้ asm อย่างง่ายที่อาจดีสำหรับ gcc เพื่อทราบวิธีการอินไลน์ (บางรุ่น gcc แบบอินไลน์ไม่คล่องrep scasbซึ่งช้ามากหรือ 4-byte-at-a-time bithack เช่นนี้ดังนั้นสูตร Inline-Strlen ของ GCC จำเป็นต้องอัปเดตหรือปิดใช้งาน)

Asm ไม่มี C-style "พฤติกรรมไม่ได้กำหนด" ; มันปลอดภัยที่จะเข้าถึงไบต์ในหน่วยความจำตามที่คุณต้องการและการโหลดที่จัดเรียงซึ่งรวมถึงไบต์ที่ถูกต้องใด ๆ จะไม่เป็นความผิด การป้องกันหน่วยความจำเกิดขึ้นพร้อมกับหน้าย่อย เข้าถึงที่จัดชิดแคบกว่าที่ไม่สามารถข้ามขอบเขตหน้า การอ่านจุดจบของบัฟเฟอร์ในหน้าเดียวกันใน x86 และ x64 ปลอดภัยหรือไม่? การใช้เหตุผลเดียวกันกับรหัสเครื่องที่แฮ็ค C นี้ได้รับคอมไพเลอร์เพื่อสร้างสำหรับการใช้งานแบบไม่ใช้อินไลน์ของฟังก์ชันนี้

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


ทำไมจึงมีความปลอดภัยในฐานะเป็นส่วนหนึ่งของ glibcแต่ไม่ใช่อย่างอื่น

ปัจจัยที่สำคัญที่สุดคือสิ่งนี้strlenไม่สามารถแทรกเข้าไปในสิ่งอื่นใดได้ มันไม่ปลอดภัยสำหรับสิ่งนั้น มันมีUB เข้มงวดในนามแฝง (อ่านcharข้อมูลผ่านunsigned long*) char*ได้รับอนุญาตให้อะไรนามแฝงอื่นแต่กลับเป็นความไม่จริง

นี่คือฟังก์ชันไลบรารีสำหรับไลบรารีที่คอมไพล์ล่วงหน้า (glibc) มันจะไม่ได้รับการอินไลน์ด้วยการเพิ่มประสิทธิภาพลิงค์เวลาเข้าไปในผู้โทร strlenซึ่งหมายความว่าเพียงแค่มีการรวบรวมรหัสเครื่องที่ปลอดภัยสำหรับรุ่นสแตนด์อะโลนของ ไม่จำเป็นต้องพกพา / ปลอดภัย C.

ไลบรารี GNU C จะต้องคอมไพล์ด้วย GCC เท่านั้น เห็นได้ชัดว่าไม่รองรับการรวบรวมด้วยเสียงดังกราวหรือ ICC แม้ว่าจะรองรับส่วนขยาย GNU GCC เป็นคอมไพเลอร์ล่วงหน้าที่เปลี่ยนไฟล์ต้นฉบับ C เป็นไฟล์ออบเจ็กต์ของรหัสเครื่อง ไม่ใช่ล่ามดังนั้นเว้นแต่ว่ามันจะอินไลน์ในเวลารวบรวมไบต์ในหน่วยความจำเป็นเพียงไบต์ในหน่วยความจำ เช่น UB ที่ใช้นามแฝงอย่างเข้มงวดไม่เป็นอันตรายเมื่อการเข้าถึงที่มีประเภทแตกต่างกันเกิดขึ้นในฟังก์ชั่นที่แตกต่างกันซึ่งไม่ได้รวมเข้าด้วยกัน

โปรดจำไว้ว่าstrlenพฤติกรรมนั้นถูกกำหนดโดยมาตรฐาน ISO C ชื่อฟังก์ชั่นนั้นโดยเฉพาะนั้นเป็นส่วนหนึ่งของการนำไปใช้งาน คอมไพเลอร์เช่น GCC แม้กระทั่งการรักษาชื่อเป็นฟังก์ชั่นเว้นแต่คุณจะใช้-fno-builtin-strlenเพื่อให้สามารถคงรวบรวมเวลาstrlen("foo") 3คำจำกัดความในไลบรารีจะใช้เฉพาะเมื่อ gcc ตัดสินใจที่จะส่งการเรียกไปที่จริงแทนที่จะใช้การทำอินไลน์สูตรของตนเองหรือบางสิ่งบางอย่าง

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

Glibc ถูกคอมไพล์ไปยังไลบรารีแบบสแตติกหรือไดนามิกแบบสแตนด์อะโลนที่ไม่สามารถอินไลน์ด้วยการปรับให้เหมาะสมเวลาลิงก์ สคริปต์การสร้างของ glibc ไม่สร้างไลบรารี่แบบคงที่ "อ้วน" ที่มีรหัสเครื่อง + gcc การเป็นตัวแทนภายในของ GIMPLE สำหรับการปรับแต่งลิงค์เวลาเมื่อทำการอินไลน์เข้าไปในโปรแกรม (คือlibc.aจะไม่ได้มีส่วนร่วมใน-fltoการเพิ่มประสิทธิภาพการเชื่อมโยงเวลาเข้าโปรแกรมหลัก.) glibc อาคารว่าวิธีการจะเป็นที่อาจไม่ปลอดภัยกับเป้าหมายที่จริงใช้นี้.c

ในความเป็นจริงตามที่ @zwol ความคิดเห็น LTO ไม่สามารถใช้เมื่อสร้าง glibc เองเพราะรหัส "เปราะ" เช่นนี้ซึ่งอาจแตกถ้า inlining ระหว่างไฟล์ต้นฉบับ glibc เป็นไปได้ (มีการใช้งานภายในบางอย่างstrlenเช่นอาจเป็นส่วนหนึ่งของการprintfใช้งาน)


นี่strlenทำให้สมมติฐานบางอย่าง:

  • CHAR_BITมีหลาย 8 เป็นจริงในระบบ GNU ทั้งหมด POSIX 2001 CHAR_BIT == 8ค้ำประกันแม้กระทั่ง (สิ่งนี้ดูปลอดภัยสำหรับระบบที่มีCHAR_BIT= 16หรือ32เช่น DSP บางอันวนลูปที่ไม่ได้จัดแนว - จะรัน 0 ซ้ำเสมอถ้าเป็นsizeof(long) = sizeof(char) = 1เพราะตัวชี้ทุกตัวอยู่ในแนวเดียวกันเสมอและp & sizeof(long)-1เป็นศูนย์เสมอ) แต่ถ้าคุณมีชุดอักขระที่ไม่ใช่ ASCII หรือกว้าง 12 บิต0x8080...เป็นรูปแบบที่ผิด
  • (อาจ) unsigned longเป็น 4 หรือ 8 ไบต์ หรืออาจใช้งานได้จริงกับขนาดunsigned longไม่เกิน 8 และใช้assert()เพื่อตรวจสอบสิ่งนั้น

UB ทั้งสองนั้นเป็นไปไม่ได้ แต่เป็นไปไม่ได้ที่จะพกพาไปใช้งาน C บางตัว รหัสนี้เป็นส่วนหนึ่งของการติดตั้ง C บนแพลตฟอร์มที่ใช้งานได้ดี

สมมติฐานถัดไปคือ C UB ที่มีศักยภาพ:

  • การโหลดแบบจัดเรียงที่ประกอบด้วยไบต์ที่ถูกต้องจะไม่เป็นความผิดและปลอดภัยตราบใดที่คุณไม่สนใจไบต์ที่อยู่นอกวัตถุที่คุณต้องการ (เป็น True ใน asm ในทุกระบบ GNU และใน CPU ปกติทั้งหมดเนื่องจากการป้องกันหน่วยความจำเกิดขึ้นกับ granularity ที่จัดเรียงหน้า มันปลอดภัยที่จะอ่านผ่านจุดสิ้นสุดของบัฟเฟอร์ภายในหน้าเดียวกันใน x86 และ x64ปลอดภัยใน C เมื่อ UB ไม่สามารถมองเห็นได้ในเวลาคอมไพล์โดยไม่มี inlining นี่เป็นกรณีนี้คอมไพเลอร์ไม่สามารถพิสูจน์ได้ว่าการอ่านที่ผ่านมาอันแรก0คือ UB มันอาจเป็นchar[]อาร์เรย์C ที่มี{1,2,0,3}ตัวอย่าง)

จุดสุดท้ายนั่นคือสิ่งที่ทำให้อ่านจุดสิ้นสุดของวัตถุ C ได้อย่างปลอดภัย มันค่อนข้างปลอดภัยแม้ในขณะที่คอมไพเลอร์กับคอมไพเลอร์ในปัจจุบันเพราะฉันคิดว่าพวกเขาไม่ปฏิบัติต่อนั่นหมายความว่าเส้นทางของการประหารชีวิตนั้นไม่สามารถเข้าถึงได้ แต่อย่างไรก็ตามการใช้นามแฝงที่เข้มงวดนั้นเป็นสิ่งที่ดีถ้าคุณปล่อยให้แบบอินไลน์นี้

ถ้าอย่างนั้นคุณก็มีปัญหาเช่นmemcpy มาโคร CPPเก่าของเคอร์เนลที่ไม่ปลอดภัยซึ่งใช้การชี้การชี้ไปที่unsigned long( gcc, aliasing ที่เข้มงวดและเรื่องสยองขวัญ )

นี้strlenวันที่กลับไปในยุคนั้นเมื่อคุณได้รับไปกับสิ่งที่ชอบในทั่วไป ; มันค่อนข้างปลอดภัยโดยไม่ต้องมีข้อแม้ "เมื่อไม่ได้อยู่ในบรรทัด" ก่อน GCC3


UB ที่มองเห็นได้เฉพาะเมื่อมองข้ามขอบเขตการโทร / สายกลับไม่สามารถทำร้ายเราได้ (เช่นการโทรแบบนี้char buf[]แทนการunsigned long[]ส่งไปยัง a const char*) เมื่อรหัสเครื่องถูกตั้งค่าเป็นเพียงแค่จัดการกับไบต์ในหน่วยความจำ การเรียกใช้ฟังก์ชั่นที่ไม่ใช่แบบอินไลน์จะต้องสมมติว่า callee อ่านหน่วยความจำใด ๆ / ทั้งหมด


เขียนสิ่งนี้อย่างปลอดภัยโดยไม่ใช้นามแฝงที่เข้มงวด

ประเภท GCC แอตทริบิวต์may_aliaschar*ให้ประเภทการรักษานามแฝงอะไรเช่นเดียวกับ (แนะนำโดย @KonradBorowsk) ส่วนหัวของ GCC ในปัจจุบันใช้สำหรับประเภทเวกเตอร์ x 86 SIMD เช่นเพื่อให้คุณสามารถเสมอได้อย่างปลอดภัยทำ__m128i _mm_loadu_si128( (__m128i*)foo )(ดูที่`reinterpret_cast` ระหว่างตัวชี้เวกเตอร์ฮาร์ดแวร์กับประเภทที่เกี่ยวข้องกับพฤติกรรมที่ไม่ได้กำหนดหรือไม่สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับสิ่งนี้จะทำและไม่ได้หมายความว่า)

strlen(const char *char_ptr)
{
  typedef unsigned long __attribute__((may_alias)) aliasing_ulong;

  aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
  for (;;) {
     unsigned long ulong = *longword_ptr++;  // can safely alias anything
     ...
  }
}

นอกจากนี้คุณยังสามารถใช้ในการแสดงประเภทด้วยaligned(1)alignof(T) = 1
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;

วิธีพกพาในการแสดงภาระการ aliasing ใน ISO ก็คือmemcpyคอมไพเลอร์สมัยใหม่จะรู้วิธี inline เป็นคำสั่งการโหลดครั้งเดียว เช่น

   unsigned long longword;
   memcpy(&longword, char_ptr, sizeof(longword));
   char_ptr += sizeof(longword);

นอกจากนี้ยังใช้งานได้กับการโหลดที่ไม่ได้จัดแนวเนื่องจากmemcpyทำงานเหมือน - โดยchar- at-a-time access แต่ในทางปฏิบัติคอมไพเลอร์สมัยใหม่เข้าใจmemcpyดีมาก

อันตรายที่นี่คือถ้า GCC ไม่ทราบแน่ชัดว่าchar_ptrมีการจัดเรียงคำมันจะไม่อินไลน์ในบางแพลตฟอร์มที่อาจไม่รองรับการโหลดที่ไม่จัดแนวใน asm เช่น MIPS ก่อน MIPS64r6 หรือ ARM ที่เก่ากว่า หากคุณได้รับการเรียกใช้ฟังก์ชันจริงmemcpyเพียงเพื่อโหลดคำ (และเก็บไว้ในหน่วยความจำอื่น) นั่นจะเป็นหายนะ GCC บางครั้งสามารถมองเห็นเมื่อรหัสจัดตำแหน่งตัวชี้ หรือหลังจากลูป char-at-a-time ที่ถึงขอบเขตอูลองที่คุณสามารถใช้ได้
p = __builtin_assume_aligned(p, sizeof(unsigned long));

สิ่งนี้ไม่ได้หลีกเลี่ยง UB ที่อ่านได้ในอดีต แต่ใน GCC ปัจจุบันนั้นไม่เป็นอันตรายในทางปฏิบัติ


เหตุใดจึงต้องมีแหล่งที่มา C ที่ปรับให้เหมาะสมด้วยมือ: คอมไพเลอร์ปัจจุบันไม่ดีพอ

asm ที่ได้รับการปรับปรุงด้วยมือนั้นสามารถทำได้ดียิ่งขึ้นเมื่อคุณต้องการประสิทธิภาพที่ลดลงทุกครั้งสำหรับฟังก์ชั่นไลบรารีมาตรฐานที่ใช้กันอย่างแพร่หลาย โดยเฉพาะอย่างยิ่งสำหรับสิ่งที่ต้องการแต่ยังmemcpy strlenในกรณีนี้มันจะไม่ง่ายกว่าการใช้ C ที่มี x86 อินทรินในการใช้ประโยชน์จาก SSE2

แต่ที่นี่เรากำลังพูดถึงรุ่นไร้เดียงสากับ Bithack C ที่ไม่มีคุณสมบัติเฉพาะของ ISA

(ฉันคิดว่าเราสามารถนำไปใช้ได้ตามที่strlenใช้อย่างกว้างขวางเพียงพอที่ทำให้มันรันเร็วที่สุดเท่าที่จะเป็นไปได้ดังนั้นคำถามจึงกลายเป็นว่าเราจะได้รับรหัสเครื่องที่มีประสิทธิภาพจากแหล่งที่ง่ายกว่าหรือไม่เราไม่สามารถทำได้)

GCC ในปัจจุบันและเสียงดังกราวไม่สามารถของลูปอัตโนมัติ vectorizing ที่นับซ้ำไม่เป็นที่รู้จักไปข้างหน้าของซ้ำแรก (เช่นต้องตรวจสอบว่าลูปจะทำงานอย่างน้อย 16 ครั้งก่อนที่จะรันการวนซ้ำครั้งแรก) เช่นการบันทึกอัตโนมัติแบบ autovectorizing memcpy เป็นไปได้ (บัฟเฟอร์ความยาวชัดแจ้งอย่างชัดเจน) แต่ไม่ใช่ strcpy หรือ strlen (ความยาวโดยนัย) คอมไพเลอร์

ซึ่งรวมถึงลูปการค้นหาหรือลูปอื่น ๆ ที่ขึ้นอยู่กับข้อมูลif()breakเช่นเดียวกับตัวนับ

ICC (คอมไพเลอร์ของ Intel สำหรับ x86) สามารถทำการวนลูปการค้นหาอัตโนมัติได้ แต่ก็ยังคงสร้าง asm แบบไม่ระบุชื่อในเวลาสำหรับ asm แบบเรียบง่าย / ไร้เดียงสาstrlenเช่น libc ของ OpenBSD ใช้ ( Godbolt ) (จากคำตอบของ @ Peske )

libc มือที่ดีที่สุดstrlenเป็นสิ่งที่จำเป็นสำหรับการทำงานกับคอมไพเลอร์ในปัจจุบัน การไปทีละ 1 ไบต์ (ด้วยการคลายอาจ 2 ไบต์ต่อรอบบนซีพียูที่มีความเร็วสูง) เป็นสิ่งที่น่าสมเพชเมื่อหน่วยความจำหลักสามารถรักษาได้ 8 ไบต์ต่อรอบและแคช L1d สามารถส่ง 16 ถึง 64 ต่อรอบ (โหลด 2x 32 ไบต์ต่อรอบในซีพียูกระแสหลัก x86 ที่ทันสมัยตั้งแต่ Haswell และ Ryzen ไม่นับ AVX512 ซึ่งสามารถลดความเร็วสัญญาณนาฬิกาสำหรับการใช้เวกเตอร์ 512 บิตซึ่งเป็นเหตุผลที่ glibc อาจไม่รีบเพิ่มรุ่น AVX512 แม้ว่าจะมีเวกเตอร์ 256 บิต แต่หน้ากาก AVX512VL + BW เปรียบเทียบกับมาสก์และktestหรือkortestอาจทำให้strlenไฮเปอร์เธรดเป็นมิตรมากขึ้นโดยการลด uops / การวนซ้ำ)

ฉันรวม non-x86 ไว้ที่นี่นั่นคือ "16 bytes" เช่นซีพียู AArch64 ส่วนใหญ่สามารถทำอย่างน้อยฉันคิดว่าและบางอย่างเพิ่มเติม และบางแห่งก็มีปริมาณงานที่เพียงพอสำหรับstrlenการติดตามแบนด์วิธโหลดนั้น

แน่นอนว่าโปรแกรมที่ทำงานกับสตริงขนาดใหญ่มักจะติดตามความยาวเพื่อหลีกเลี่ยงการทำซ้ำการค้นหาความยาวของสตริง C โดยนัยยาวบ่อยมาก แต่ประสิทธิภาพความยาวสั้นถึงปานกลางยังคงได้รับประโยชน์จากการใช้งานที่เขียนด้วยมือและฉันมั่นใจว่าบางโปรแกรมจะจบลงด้วยการใช้ strlen บนสตริงที่มีความยาวปานกลาง


12
หมายเหตุเล็กน้อย: (1) ขณะนี้ยังไม่สามารถรวบรวม glibc เองกับคอมไพเลอร์อื่นที่ไม่ใช่ GCC (2) ในปัจจุบันยังไม่สามารถรวบรวม glibc ได้ด้วยการเปิดใช้งานการเพิ่มประสิทธิภาพเวลาเชื่อมโยงเนื่องจากกรณีต่างๆเหล่านี้อย่างแม่นยำซึ่งคอมไพเลอร์จะเห็น UB หากอินไลน์ถูกอนุญาตให้เกิดขึ้น (3) CHAR_BIT == 8เป็นข้อกำหนด POSIX (ตั้งแต่ -2001 รอบดูที่นี่ ) (4) การใช้ C fallback strlenสำหรับซีพียูที่รองรับบางตัวผมเชื่อว่าส่วนใหญ่มักจะเป็น MIPS
zwol

1
ที่น่าสนใจ UB ที่มีนามแฝงที่เข้มงวดสามารถแก้ไขได้โดยใช้__attribute__((__may_alias__))คุณลักษณะ (นี่ไม่ใช่แบบพกพา แต่ควรจะดีสำหรับ glibc)
Konrad Borowski

1
@SebastianRedl: คุณสามารถอ่าน / เขียนวัตถุใด ๆ ผ่านchar*แต่ก็ยังคง UB การอ่าน / เขียนchar วัตถุ (เช่นส่วนหนึ่งของchar[]) long*ผ่าน กฎนามแฝงที่เข้มงวดและพอยน์
เตอร์

1
มาตรฐาน C และ C ++ บอกว่าCHAR_BITต้องมีอย่างน้อย 8 ( qvภาคผนวก E ของ C11) ดังนั้นอย่างน้อย 7 บิตcharไม่ใช่สิ่งที่ทนายความภาษาต้องกังวล สิ่งนี้ได้รับแรงบันดาลใจจากความต้องการ“ สำหรับ UTF − 8 ตัวอักษรสตริงสตริงองค์ประกอบอาเรย์มีชนิดcharและเริ่มต้นด้วยอักขระของลำดับอักขระแบบมัลติไบต์ตามที่เข้ารหัสใน UTF − 8”
Davislor

2
ดูเหมือนว่าการวิเคราะห์นี้เป็นพื้นฐานที่ดีสำหรับการเสนอแพทช์ทำให้รหัสมีความแข็งแกร่งมากขึ้นเมื่อเผชิญกับการเพิ่มประสิทธิภาพที่ปิดการใช้งานในปัจจุบันนอกเหนือจากการตอบคำถามที่ยอดเยี่ยม
Deduplicator

61

มีการอธิบายในความคิดเห็นในไฟล์ที่คุณเชื่อมโยง:

 27 /* Return the length of the null-terminated string STR.  Scan for
 28    the null terminator quickly by testing four bytes at a time.  */

และ:

 73   /* Instead of the traditional loop which tests each character,
 74      we will test a longword at a time.  The tricky part is testing
 75      if *any of the four* bytes in the longword in question are zero.  */

ใน C เป็นไปได้ที่จะให้เหตุผลโดยละเอียดเกี่ยวกับประสิทธิภาพ

จะมีประสิทธิภาพน้อยกว่าในการวนซ้ำอักขระแต่ละตัวที่มองหาโมฆะมากกว่าการทดสอบครั้งละมากกว่าหนึ่งไบต์ตามที่รหัสนี้ทำ

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

ในการพัฒนาซอฟต์แวร์ที่ทันสมัยส่วนใหญ่ (แต่ไม่ใช่ทั้งหมด) การใส่ใจในรายละเอียดประสิทธิภาพนี้ไม่จำเป็นหรือไม่คุ้มค่ากับความซับซ้อนของรหัสเพิ่มเติม

ที่เดียวที่ควรคำนึงถึงประสิทธิภาพเช่นนี้อยู่ในห้องสมุดมาตรฐานเช่นเดียวกับตัวอย่างที่คุณเชื่อมโยง


หากคุณต้องการอ่านเพิ่มเติมเกี่ยวกับขอบเขตของคำให้ดูคำถามนี้และหน้าวิกิพีเดียที่ยอดเยี่ยมนี้


39

strlenนอกจากนี้ยังมีคำตอบที่ดีที่นี่ผมอยากจะชี้ให้เห็นว่ารหัสที่เชื่อมโยงในคำถามคือสำหรับการดำเนินงานของกนูของ

การใช้งาน OpenBSDstrlenนั้นคล้ายกับรหัสที่เสนอในคำถาม ความซับซ้อนของการใช้งานจะถูกกำหนดโดยผู้เขียน

...
#include <string.h>

size_t
strlen(const char *str)
{
    const char *s;

    for (s = str; *s; ++s)
        ;
    return (s - str);
}

DEF_STRONG(strlen);

แก้ไข : รหัส OpenBSD ที่ฉันเชื่อมโยงข้างต้นดูเหมือนจะเป็นการใช้ทางเลือกสำหรับ ISAs ที่ไม่มีการใช้ asm มีการใช้งานที่แตกต่างกันstrlenขึ้นอยู่กับสถาปัตยกรรม ตัวอย่างรหัสสำหรับamd64strlenคือ asm คล้ายกับข้อคิดเห็น / คำตอบของ PeterCordes ชี้ให้เห็นว่าการใช้งาน GNU ที่ไม่ใช่ทางเลือกนั้นเป็น asm เช่นกัน


5
นี่เป็นภาพประกอบที่ดีมากเกี่ยวกับค่าที่แตกต่างซึ่งได้รับการปรับให้เหมาะสมในเครื่องมือ OpenBSD กับ GNU
เจสัน

11
มันใช้งานทางเลือกแบบพกพาของ glibc ISAs สำคัญทั้งหมดมีการใช้งาน asm ที่เขียนด้วยมือใน glibc โดยใช้ SIMD เมื่อมันช่วยได้ (เช่นบน x86) ดูcode.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/...และcode.woboq.org/userspace/glibc/sysdeps/aarch64/multiarch/...
ปีเตอร์ Cordes

4
แม้แต่เวอร์ชั่น OpenBSD ก็ยังมีข้อบกพร่องที่หลีกเลี่ยงไม่ได้! ลักษณะการทำงานของไม่ได้กำหนดถ้าผลที่ได้คือไม่ได้อยู่ในที่แทนs - str ptrdiff_t
Antti Haapala

1
@AnttiHaapala: ใน GNU C, PTRDIFF_MAXขนาดวัตถุสูงสุดคือ แต่ก็ยังเป็นไปได้ที่จะmmapมีหน่วยความจำมากกว่านั้นบน Linux อย่างน้อย (เช่นในกระบวนการ 32 บิตภายใต้เคอร์เนล x86-64 ฉันสามารถ mmap ประมาณ 2.7GB ติดกันก่อนที่ฉันจะเริ่มล้มเหลว) IDK เกี่ยวกับ OpenBSD; เคอร์เนลสามารถทำให้มันเป็นไปไม่ได้ที่จะไปถึงที่นั่นreturnโดยไม่ต้องแยกย่อยหรือหยุดภายในขนาด แต่ใช่คุณคิดว่าการเข้ารหัสป้องกันที่หลีกเลี่ยงทฤษฎี C UB จะเป็นสิ่งที่ OpenBSD ต้องการทำ แม้ว่าstrlenคอมไพเลอร์แบบอินไลน์ไม่สามารถทำได้แต่ตัวแปลที่แท้จริงจะคอมไพล์มันเป็นลบ
ปีเตอร์

2
@PeterCordes อย่างแน่นอน สิ่งเดียวกันใน OpenBSD เช่นชุดประกอบ i386: cvsweb.openbsd.org/cgi-bin/cvsweb/src/lib/libc/arch/i386/string/…
dchest

34

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

เพื่ออธิบายการทำงานของมันให้พิจารณาภาพต่อไปนี้ สมมติว่าแพลตฟอร์ม 32- บิตที่นี่ (การจัดตำแหน่ง 4 ไบต์)

สมมติว่าตัวอักษร "H" ของ "Hello, world!" strlenสตริงถูกจัดให้เป็นอาร์กิวเมนต์สำหรับ เนื่องจาก CPU ชอบมีสิ่งต่าง ๆ จัดอยู่ในหน่วยความจำ (นึกคิดaddress % sizeof(size_t) == 0), ไบต์ก่อนการจัดตำแหน่งจะถูกประมวลผลไบต์ต่อไบต์โดยใช้วิธีการช้า

จากนั้นสำหรับแต่ละก้อนขนาดการจัดตำแหน่งโดยการคำนวณ(longbits - 0x01010101) & 0x80808080 != 0มันจะตรวจสอบว่าใด ๆ ของไบต์ภายในจำนวนเต็มเป็นศูนย์ การคำนวณนี้มีค่าเป็นบวกเท็จเมื่ออย่างน้อยหนึ่งไบต์สูงกว่า0x80แต่บ่อยกว่าที่ควรจะเป็น หากไม่ใช่ในกรณี (เนื่องจากอยู่ในพื้นที่สีเหลือง) ความยาวจะเพิ่มขึ้นตามขนาดการจัดตำแหน่ง

หากไบต์ภายในใด ๆ เป็นจำนวนเต็มจะกลายเป็นศูนย์ (หรือ0x81) จากนั้นสตริงจะถูกตรวจสอบไบต์ต่อไบต์เพื่อกำหนดตำแหน่งของศูนย์

สิ่งนี้สามารถทำให้การเข้าถึงนอกขอบเขตได้ แต่เนื่องจากอยู่ในการจัดแนวจึงมีแนวโน้มที่จะไม่ปรับหน่วยการแมปหน่วยความจำมักไม่มีความแม่นยำระดับไบต์


การใช้งานนี้เป็นส่วนหนึ่งของ glibc ระบบ GNU ป้องกันหน่วยความจำด้วยหน้าย่อย ดังนั้นใช่โหลดที่จัดเรียงซึ่งรวมถึงไบต์ที่ถูกต้องจะปลอดภัย
Peter Cordes

size_tไม่รับประกันว่าจะจัดตำแหน่ง
SS Anne

32

คุณต้องการให้รหัสถูกต้องบำรุงรักษาและรวดเร็ว ปัจจัยเหล่านี้มีความสำคัญแตกต่างกัน:

"ถูกต้อง" เป็นสิ่งจำเป็นอย่างยิ่ง

"maintainable" ขึ้นอยู่กับว่าคุณจะรักษารหัสไว้เท่าไหร่: strlen เป็นฟังก์ชันไลบรารีมาตรฐาน C มานานกว่า 40 ปี มันจะไม่เปลี่ยนแปลง การบำรุงรักษาจึงค่อนข้างไม่สำคัญสำหรับฟังก์ชั่นนี้

"เร็ว": ในแอปพลิเคชั่นจำนวนมาก strcpy, strlen ฯลฯ ใช้เวลาในการประมวลผลจำนวนมาก เพื่อให้ได้ความเร็วโดยรวมที่เพิ่มขึ้นเช่นเดียวกับความซับซ้อนนี้ แต่การใช้ strlen ที่ไม่ซับซ้อนโดยการปรับปรุงคอมไพเลอร์จะต้องใช้ความพยายามอย่างกล้าหาญ

ความรวดเร็วมีข้อดีอีกประการหนึ่งคือเมื่อโปรแกรมเมอร์ค้นพบว่าการเรียก "strlen" เป็นวิธีที่เร็วที่สุดที่พวกเขาสามารถวัดจำนวนไบต์ในสตริงได้พวกเขาจะไม่ถูกล่อลวงให้เขียนโค้ดของตัวเองอีกต่อไป

ดังนั้นสำหรับ strlen ความเร็วนั้นสำคัญมากและการบำรุงรักษานั้นสำคัญน้อยกว่าโค้ดส่วนใหญ่ที่คุณจะเขียน

ทำไมต้องมีความซับซ้อน สมมติว่าคุณมีสตริง 1,000 ไบต์ การติดตั้งอย่างง่ายจะตรวจสอบ 1,000 ไบต์ การใช้งานในปัจจุบันมีแนวโน้มที่จะตรวจสอบคำ 64 บิตในแต่ละครั้งซึ่งหมายถึงคำ 125 64 บิตหรือแปดไบต์ อาจใช้คำแนะนำแบบเวกเตอร์ตรวจสอบขนาด 32 ไบต์ต่อครั้งซึ่งจะยิ่งซับซ้อนและเร็วยิ่งขึ้น การใช้คำแนะนำเวกเตอร์นำไปสู่รหัสที่ซับซ้อนกว่าเล็กน้อย แต่ค่อนข้างตรงไปตรงมาตรวจสอบว่าหนึ่งในแปดไบต์ในคำ 64 บิตเป็นศูนย์ต้องใช้เทคนิคที่ฉลาด ดังนั้นสำหรับสตริงขนาดกลางถึงยาวรหัสนี้สามารถคาดว่าจะเร็วขึ้นประมาณสี่เท่า สำหรับฟังก์ชั่นที่มีความสำคัญเทียบเท่ากับสแตรนนั่นคือการเขียนฟังก์ชั่นที่ซับซ้อนมากขึ้น

PS รหัสไม่ได้พกพามาก แต่เป็นส่วนหนึ่งของไลบรารี Standard C ซึ่งเป็นส่วนหนึ่งของการนำไปใช้ - ไม่จำเป็นต้องพกพา

PPS มีคนโพสต์ตัวอย่างที่มีเครื่องมือการดีบักที่บ่นเกี่ยวกับการเข้าถึงไบต์ที่ส่วนท้ายของสตริง การใช้งานสามารถออกแบบที่รับประกันต่อไปนี้: ถ้า p เป็นตัวชี้ที่ถูกต้องไปยังไบต์แล้วเข้าถึงไบต์ใด ๆ ในบล็อกชิดเดียวกันที่จะไม่ได้กำหนดพฤติกรรมตามมาตรฐาน C จะส่งกลับค่าที่ไม่ได้ระบุ

PPPS Intel ได้เพิ่มคำแนะนำให้กับโปรเซสเซอร์ในภายหลังซึ่งเป็นแบบเอกสารสำเร็จรูปสำหรับฟังก์ชั่น strstr () (ค้นหาสตริงย่อยในสตริง) คำอธิบายของพวกเขาคือความเชื่อที่เหลือเชื่อ แต่พวกเขาสามารถทำให้ฟังก์ชั่นนั้นน่าจะเร็วขึ้น 100 เท่า (โดยทั่วไปให้อาร์เรย์ที่มี "Hello, world!" และอาร์เรย์ b ที่เริ่มต้นด้วย 16 ไบต์ "HelloHelloHelloH" และมีไบต์มากขึ้นซึ่งเป็นตัวเลขที่สตริง a ไม่เกิดขึ้นใน b เร็วกว่าการเริ่มต้นที่ดัชนี 15) .


หรือ ... หากฉันพบว่าฉันกำลังประมวลผลตามสายอักขระจำนวนมากและมีคอขวดฉันอาจจะใช้ Pascal Strings รุ่นของตัวเองแทนที่จะปรับปรุง strlen ...
Baldrickk

1
ไม่มีใครขอให้คุณพัฒนา strlen แต่ทำให้ดีพอที่จะหลีกเลี่ยงเรื่องไร้สาระเช่นเดียวกับคนที่ใช้สายของตัวเอง
gnasher729

strlen () จะตื้อบางครั้ง
ปีเตอร์มอร์เทนเซ่น

24

สั้น ๆ : การตรวจสอบสตริงไบต์ต่อไบต์อาจช้ากับสถาปัตยกรรมที่สามารถดึงข้อมูลจำนวนมากในเวลาเดียวกัน

หากการตรวจสอบการยกเลิก null สามารถทำได้บนพื้นฐาน 32 หรือ 64 บิตจะช่วยลดจำนวนการตรวจสอบที่คอมไพเลอร์ได้ดำเนินการ นั่นคือสิ่งที่รหัสที่เชื่อมโยงพยายามทำโดยคำนึงถึงระบบเฉพาะ พวกเขาตั้งสมมติฐานเกี่ยวกับการจัดการการจัดตำแหน่งการใช้แคชการตั้งค่าคอมไพเลอร์ที่ไม่ได้มาตรฐาน ฯลฯ

การอ่านไบต์ต่อไบต์ตามตัวอย่างของคุณน่าจะเป็นวิธีการที่เหมาะสมสำหรับ CPU 8 บิตหรือเมื่อเขียน lib แบบพกพาที่เขียนด้วยมาตรฐาน C

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


1
แน่นอนว่าเครื่องมือเพิ่มประสิทธิภาพมีความเป็นไปได้สูงที่จะคลี่คลายหรือปรับเวกเตอร์อัตโนมัติห่วงนี้และ pre-fetcher สามารถตรวจจับรูปแบบการเข้าถึงนี้ได้เล็กน้อย เทคนิคเหล่านี้มีความสำคัญต่อตัวประมวลผลสมัยใหม่หรือไม่หากจำเป็นต้องทดสอบ หากมีการชนะที่จะเป็นไปได้ว่าอาจใช้คำแนะนำแบบเวกเตอร์
russbishop

6
@russbishop: คุณหวังไว้ แต่ไม่ GCC และเสียงดังกราวนั้นไม่มีความสามารถในการวนลูปอัตโนมัติที่ไม่มีการนับซ้ำซ้ำก่อนการทำซ้ำครั้งแรก ที่มีลูปการค้นหาหรือวงอื่น ๆ if()breakที่มีขึ้นอยู่กับข้อมูล ICC สามารถทำการวนซ้ำอัตโนมัติแบบเวกเตอร์ได้ แต่ IDK ทำได้ดีแค่ไหนกับ strlen ที่ไร้เดียงสา และใช่ SSE2 pcmpeqb/ pmovmskbเป็นอย่างมากที่ดีสำหรับ strlen ทดสอบ 16 ไบต์ในเวลา code.woboq.org/userspace/glibc/sysdeps/x86_64/strlen.S.htmlเป็นเวอร์ชั่น SSE2 ของ glibc ดูคำถามและคำตอบนี้ด้วย
ปีเตอร์

Oof นั่นคือโชคร้าย ฉันมักจะต่อต้าน UB มาก แต่ในขณะที่คุณชี้ให้เห็นว่าสตริง C ต้องการการอ่านจุดสิ้นสุดของบัฟเฟอร์ UB ในทางเทคนิคเพื่อให้อนุญาต vectorization ฉันคิดว่าเช่นเดียวกันกับ ARM64 เนื่องจากต้องมีการจัดตำแหน่ง
russbishop

-6

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

อย่าอ้างถึงซอร์สโค้ดของ Unix ไม่ว่าในกรณีใดหรือระหว่างทำงานกับ GNU! (หรือโปรแกรมที่เป็นกรรมสิทธิ์อื่น ๆ )

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

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

(ของฉันเน้น)


5
คำถามนี้จะตอบคำถามได้อย่างไร
SS Anne

1
คำถามใน OP คือ "รหัสที่ง่ายกว่านี้จะไม่ทำงานได้ดีกว่าหรือไม่" และนั่นเป็นคำถามที่ไม่ได้ตัดสินใจในเรื่องของการทำบุญทางเทคนิคเสมอไป สำหรับโครงการอย่าง GNU การหลีกเลี่ยงข้อผิดพลาดทางกฎหมายเป็นส่วนสำคัญของรหัส "ทำงานได้ดีกว่า" และการใช้งาน "ชัดเจน" ของstrlen()มีแนวโน้มที่จะออกมาคล้ายหรือคล้ายกับรหัสที่มีอยู่ บางสิ่งที่ "บ้า" ในการใช้งานของ glibc นั้นไม่สามารถย้อนกลับมาได้อีก พิจารณาว่ามีการถกเถียงทางกฎหมายมากน้อยเพียงใดrangeCheck- รหัส 11 บรรทัด! - ในการต่อสู้ของ Google / Oracle ผมว่าความกังวลของ FSF นั้นอยู่ในตำแหน่งที่ดี
Jack Kelly
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.