>  기사  >  백엔드 개발  >  성능 탐구 3부: C Force

성능 탐구 3부: C Force

王林
王林원래의
2024-08-06 01:10:021173검색

The Quest for Performance Part III : C Force

이 시리즈의 이전 두 기사에서는 Perl의 부동 연산 성능을 고려했습니다.
cos(sin(sqrt(x))) 함수를 계산하는 장난감 예제의 Python 및 R. 여기서 x는 50M 배정밀도 부동 숫자로 구성된 매우 큰 배열입니다.
산술 집약적인 부분을 C에 위임한 하이브리드 구현은 가장 성능이 뛰어난 구현 중 하나였습니다. 이번 회에서는 약간 다른 내용으로 장난감 예제의 순수 C 코드 구현 성능을 살펴보겠습니다.
C 코드는 성능을 위한 메모리 지역성의 중요성에 대한 추가 통찰력을 제공합니다(기본적으로 C 배열의 요소는 메모리의 순차 주소와 PDL 또는 해당 컨테이너와의 numpy 인터페이스와 같은 숫자 API에 저장됨). ,
예를 들어 메모리의 순차 주소에 값을 저장하지 않는 Perl 배열입니다. 마지막으로 C 코드 구현을 통해 저수준 컴파일러(이 경우 gcc)의 부동 소수점 연산과 관련된 플래그가 성능에 영향을 미칠 수 있는지 여부를 평가할 수 있습니다.
이 점은 강조할 가치가 있습니다. 일반 사용자는 "설치"를 "파이핑"하거나 인라인 파일을 빌드할 때 컴파일러 플래그 선택에 전적으로 의존합니다. 이 플래그를 건드리지 않으면 플래그가 무엇을 놓쳤는지, 피하고 있는지 전혀 알 수 없게 됩니다.
간단한 C 파일 makefile을 사용하면 이러한 성능 평가를 명시적으로 수행할 수 있습니다.

장난감 예제의 C 코드 전체가 아래에 나열되어 있습니다. 코드는 설명이 필요 없기 때문에

에 대한 네 가지 기능이 포함되어 있다는 점을 지적하는 것 외에는 설명하는 데 시간을 소비하지 않을 것입니다.
  • 비용이 많이 드는 함수의 비순차 계산: 세 가지 부동 포인팅 작업 모두 하나의 스레드를 사용하여 단일 루프 내에서 수행됩니다
  • 비용이 많이 드는 함수의 순차적 계산: 3개의 부동 소수점 함수 평가 각각은 하나의 스레드를 사용하여 별도의 루프 내부에서 수행됩니다
  • 비순차 OpenMP 코드 : 비순차 코드의 스레드 버전
  • 순차 OpenMP 코드: 순차 코드의 스레드

이 경우 컴파일러가 제곱근이 어셈블리의 압축된(벡터화된) 부동 소수점 연산에 매핑된다는 것을 인식할 만큼 똑똑하여 하나의 함수가 적절한 SIMD 명령어를 사용하여 벡터화될 수 있기를 바랄 수 있습니다. OpenMP 코드에는 simd 프로그램을 사용하지 마십시오.
아마도 벡터화로 인한 속도 향상은 동일한 메모리 위치에 반복적으로 액세스하거나 액세스하지 않음으로 인한 성능 손실을 상쇄할 수 있습니다.

#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <stdio.h>
#include <omp.h>

// simulates a large array of random numbers
double*  simulate_array(int num_of_elements,int seed);
// OMP environment functions
void _set_openmp_schedule_from_env();
void _set_num_threads_from_env();



// functions to modify C arrays 
void map_c_array(double* array, int len);
void map_c_array_sequential(double* array, int len);
void map_C_array_using_OMP(double* array, int len);
void map_C_array_sequential_using_OMP(double* array, int len);

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s <array_size>\n", argv[0]);
        return 1;
    }

    int array_size = atoi(argv[1]);
    // printf the array size
    printf("Array size: %d\n", array_size);
    double *array = simulate_array(array_size, 1234);

    // Set OMP environment
    _set_openmp_schedule_from_env();
    _set_num_threads_from_env();

    // Perform calculations and collect timing data
    double start_time, end_time, elapsed_time;
    // Non-Sequential calculation
    start_time = omp_get_wtime();
    map_c_array(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Non-sequential calculation time: %f seconds\n", elapsed_time);
    free(array);

    // Sequential calculation
    array = simulate_array(array_size, 1234);
    start_time = omp_get_wtime();
    map_c_array_sequential(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Sequential calculation time: %f seconds\n", elapsed_time);
    free(array);

    array = simulate_array(array_size, 1234);
    // Parallel calculation using OMP
    start_time = omp_get_wtime();
    map_C_array_using_OMP(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Parallel calculation using OMP time: %f seconds\n", elapsed_time);
    free(array);

    // Sequential calculation using OMP
    array = simulate_array(array_size, 1234);
    start_time = omp_get_wtime();
    map_C_array_sequential_using_OMP(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Sequential calculation using OMP time: %f seconds\n", elapsed_time);

    free(array);
    return 0;
}



/*
*******************************************************************************
* OMP environment functions
*******************************************************************************
*/
void _set_openmp_schedule_from_env() {
  char *schedule_env = getenv("OMP_SCHEDULE");
  printf("Schedule from env %s\n", getenv("OMP_SCHEDULE"));
  if (schedule_env != NULL) {
    char *kind_str = strtok(schedule_env, ",");
    char *chunk_size_str = strtok(NULL, ",");

    omp_sched_t kind;
    if (strcmp(kind_str, "static") == 0) {
      kind = omp_sched_static;
    } else if (strcmp(kind_str, "dynamic") == 0) {
      kind = omp_sched_dynamic;
    } else if (strcmp(kind_str, "guided") == 0) {
      kind = omp_sched_guided;
    } else {
      kind = omp_sched_auto;
    }
    int chunk_size = atoi(chunk_size_str);
    omp_set_schedule(kind, chunk_size);
  }
}

void _set_num_threads_from_env() {
  char *num = getenv("OMP_NUM_THREADS");
  printf("Number of threads = %s from within C\n", num);
  omp_set_num_threads(atoi(num));
}
/*
*******************************************************************************
* Functions that modify C arrays whose address is passed from Perl in C
*******************************************************************************
*/

double*  simulate_array(int num_of_elements, int seed) {
  srand(seed); // Seed the random number generator
  double *array = (double *)malloc(num_of_elements * sizeof(double));
  for (int i = 0; i < num_of_elements; i++) {
    array[i] =
        (double)rand() / RAND_MAX; // Generate a random double between 0 and 1
  }
  return array;
}

void map_c_array(double *array, int len) {
  for (int i = 0; i < len; i++) {
    array[i] = cos(sin(sqrt(array[i])));
  }
}

void map_c_array_sequential(double* array, int len) {
  for (int i = 0; i < len; i++) {
    array[i] = sqrt(array[i]);
  }
  for (int i = 0; i < len; i++) {
    array[i] = sin(array[i]);
  }
  for (int i = 0; i < len; i++) {
    array[i] = cos(array[i]);
  }
}

void map_C_array_using_OMP(double* array, int len) {
#pragma omp parallel
  {
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = cos(sin(sqrt(array[i])));
    }
  }
}

void map_C_array_sequential_using_OMP(double* array, int len) {
#pragma omp parallel
  {
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = sqrt(array[i]);
    }
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = sin(array[i]);
    }
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = cos(array[i]);
    }
  }
}

중요한 질문은 코드의 정확성을 위해 속도를 희생하는 빠른 부동 컴파일러 플래그를 사용하는 것이 성능에 영향을 미칠 수 있는지 여부입니다.
다음은 이 컴파일러 플래그가 없는 makefile입니다

CC = gcc
CFLAGS = -O3 -ftree-vectorize  -march=native  -Wall -std=gnu11 -fopenmp -fstrict-aliasing 
LDFLAGS = -fPIE -fopenmp
LIBS =  -lm

SOURCES = inplace_array_mod_with_OpenMP.c
OBJECTS = $(SOURCES:.c=_noffmath_gcc.o)
EXECUTABLE = inplace_array_mod_with_OpenMP_noffmath_gcc

all: $(SOURCES) $(EXECUTABLE)

clean:
    rm -f $(OBJECTS) $(EXECUTABLE)

$(EXECUTABLE): $(OBJECTS)
    $(CC) $(LDFLAGS) $(OBJECTS) $(LIBS) -o $@

%_noffmath_gcc.o : %.c 
    $(CC) $(CFLAGS) -c $< -o $@

이 플래그가 있는 항목은 다음과 같습니다.

CC = gcc
CFLAGS = -O3 -ftree-vectorize  -march=native -Wall -std=gnu11 -fopenmp -fstrict-aliasing -ffast-math
LDFLAGS = -fPIE -fopenmp
LIBS =  -lm

SOURCES = inplace_array_mod_with_OpenMP.c
OBJECTS = $(SOURCES:.c=_gcc.o)
EXECUTABLE = inplace_array_mod_with_OpenMP_gcc

all: $(SOURCES) $(EXECUTABLE)

clean:
    rm -f $(OBJECTS) $(EXECUTABLE)

$(EXECUTABLE): $(OBJECTS)
    $(CC) $(LDFLAGS) $(OBJECTS) $(LIBS) -o $@

%_gcc.o : %.c 
    $(CC) $(CFLAGS) -c $< -o $@

그리고 이 두 프로그램을 실행한 결과는 다음과 같습니다

  • -ffast-math 제외
OMP_SCHEDULE=guided,1 OMP_NUM_THREADS=8 ./inplace_array_mod_with_OpenMP_noffmath_gcc 50000000
Array size: 50000000
Schedule from env guided,1
Number of threads = 8 from within C
Non-sequential calculation time: 1.12 seconds
Sequential calculation time: 0.95 seconds
Parallel calculation using OMP time: 0.17 seconds
Sequential calculation using OMP time: 0.15 seconds
  • -ffast-math 사용
OMP_SCHEDULE=guided,1 OMP_NUM_THREADS=8 ./inplace_array_mod_with_OpenMP_gcc 50000000
Array size: 50000000
Schedule from env guided,1
Number of threads = 8 from within C
Non-sequential calculation time: 0.27 seconds
Sequential calculation time: 0.28 seconds
Parallel calculation using OMP time: 0.05 seconds
Sequential calculation using OMP time: 0.06 seconds

다음과 같이 Numba 코드에서 fastmath를 사용할 수 있습니다(기본값은 fastmath=False).

@njit(nogil=True,fastmath=True)
def compute_inplace_with_numba(array):
    np.sqrt(array,array)
    np.sin(array,array)
    np.cos(array,array)

주목할 만한 몇 가지 사항:

  • -ffast-math는 성능을 크게 향상시키지만(단일 스레드 및 다중 스레드 코드 모두에서 약 300%) 잘못된 결과를 생성할 수 있습니다
  • Fastmath는 Numba에서도 작동하지만 동일한 이유로 피해야 하며 정확성을 추구하는 모든 응용 프로그램에서는 피해야 합니다
  • 순차 C 단일 스레드 코드는 단일 스레드 PDL 및 Numpy와 유사한 성능을 제공합니다
  • 다소 놀랍게도 올바른(빠르지 않은) 수학을 사용할 경우 순차 코드는 비순차 코드보다 약 20% 더 빠릅니다.
  • 당연히 멀티 스레드 코드가 단일 스레드 코드보다 빠릅니다 :)
  • 이 간단한 함수의 C 코드에 비해 numbas가 어떻게 50%의 성능 프리미엄을 제공하는지 아직도 설명할 수 없습니다.

제목: " 퍼포먼스를 향한 탐구 3부 : C Force "

날짜: 2024-07-07

이 시리즈의 이전 두 기사에서는 Perl의 부동 연산 성능을 고려했습니다.
cos(sin(sqrt(x))) 함수를 계산하는 장난감 예제의 Python 및 R. 여기서 x는 50M 배정밀도 부동 숫자로 구성된 매우 큰 배열입니다.
산술 집약적인 부분을 C에 위임한 하이브리드 구현은 가장 성능이 뛰어난 구현 중 하나였습니다. 이번 회에서는 약간 다른 내용으로 장난감 예제의 순수 C 코드 구현 성능을 살펴보겠습니다.
C 코드는 성능을 위한 메모리 지역성의 중요성에 대한 추가 통찰력을 제공합니다(기본적으로 C 배열의 요소는 메모리의 순차 주소와 PDL 또는 해당 컨테이너와의 numpy 인터페이스와 같은 숫자 API에 저장됨). ,
예를 들어 메모리의 순차 주소에 값을 저장하지 않는 Perl 배열입니다. 마지막으로 C 코드 구현을 통해 저수준 컴파일러(이 경우 gcc)의 부동 소수점 연산과 관련된 플래그가 성능에 영향을 미칠 수 있는지 여부를 평가할 수 있습니다.
이 점은 강조할 가치가 있습니다. 일반 사용자는 "설치"를 "파이핑"하거나 인라인 파일을 빌드할 때 컴파일러 플래그 선택에 전적으로 의존합니다. 이 플래그를 건드리지 않으면 플래그가 무엇을 놓쳤는지, 피하고 있는지 전혀 알 수 없게 됩니다.
간단한 C 파일 makefile을 사용하면 이러한 성능 평가를 명시적으로 수행할 수 있습니다.

장난감 예제의 C 코드 전체가 아래에 나열되어 있습니다. 코드는 설명이 필요 없기 때문에

에 대한 네 가지 기능이 포함되어 있다는 점을 지적하는 것 외에는 설명하는 데 시간을 할애하지 않을 것입니다.
  • 비용이 많이 드는 함수의 비순차 계산: 세 가지 부동 포인팅 작업 모두 하나의 스레드를 사용하여 단일 루프 내에서 수행됩니다
  • 비용이 많이 드는 함수의 순차적 계산: 3개의 부동 소수점 함수 평가 각각은 하나의 스레드를 사용하여 별도의 루프 내부에서 수행됩니다
  • 비순차 OpenMP 코드 : 비순차 코드의 스레드 버전
  • 순차 OpenMP 코드: 순차 코드의 스레드

이 경우 컴파일러가 제곱근이 어셈블리의 압축된(벡터화된) 부동 소수점 연산에 매핑된다는 것을 인식할 만큼 똑똑하여 하나의 함수가 적절한 SIMD 명령어를 사용하여 벡터화될 수 있기를 바랄 수 있습니다. OpenMP 코드에는 simd 프로그램을 사용하지 마십시오.
아마도 벡터화로 인한 속도 향상은 동일한 메모리 위치에 반복적으로 액세스하거나 액세스하지 않음으로 인한 성능 손실을 상쇄할 수 있습니다.

#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <stdio.h>
#include <omp.h>

// simulates a large array of random numbers
double*  simulate_array(int num_of_elements,int seed);
// OMP environment functions
void _set_openmp_schedule_from_env();
void _set_num_threads_from_env();



// functions to modify C arrays 
void map_c_array(double* array, int len);
void map_c_array_sequential(double* array, int len);
void map_C_array_using_OMP(double* array, int len);
void map_C_array_sequential_using_OMP(double* array, int len);

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s <array_size>\n", argv[0]);
        return 1;
    }

    int array_size = atoi(argv[1]);
    // printf the array size
    printf("Array size: %d\n", array_size);
    double *array = simulate_array(array_size, 1234);

    // Set OMP environment
    _set_openmp_schedule_from_env();
    _set_num_threads_from_env();

    // Perform calculations and collect timing data
    double start_time, end_time, elapsed_time;
    // Non-Sequential calculation
    start_time = omp_get_wtime();
    map_c_array(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Non-sequential calculation time: %f seconds\n", elapsed_time);
    free(array);

    // Sequential calculation
    array = simulate_array(array_size, 1234);
    start_time = omp_get_wtime();
    map_c_array_sequential(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Sequential calculation time: %f seconds\n", elapsed_time);
    free(array);

    array = simulate_array(array_size, 1234);
    // Parallel calculation using OMP
    start_time = omp_get_wtime();
    map_C_array_using_OMP(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Parallel calculation using OMP time: %f seconds\n", elapsed_time);
    free(array);

    // Sequential calculation using OMP
    array = simulate_array(array_size, 1234);
    start_time = omp_get_wtime();
    map_C_array_sequential_using_OMP(array, array_size);
    end_time = omp_get_wtime();
    elapsed_time = end_time - start_time;
    printf("Sequential calculation using OMP time: %f seconds\n", elapsed_time);

    free(array);
    return 0;
}



/*
*******************************************************************************
* OMP environment functions
*******************************************************************************
*/
void _set_openmp_schedule_from_env() {
  char *schedule_env = getenv("OMP_SCHEDULE");
  printf("Schedule from env %s\n", getenv("OMP_SCHEDULE"));
  if (schedule_env != NULL) {
    char *kind_str = strtok(schedule_env, ",");
    char *chunk_size_str = strtok(NULL, ",");

    omp_sched_t kind;
    if (strcmp(kind_str, "static") == 0) {
      kind = omp_sched_static;
    } else if (strcmp(kind_str, "dynamic") == 0) {
      kind = omp_sched_dynamic;
    } else if (strcmp(kind_str, "guided") == 0) {
      kind = omp_sched_guided;
    } else {
      kind = omp_sched_auto;
    }
    int chunk_size = atoi(chunk_size_str);
    omp_set_schedule(kind, chunk_size);
  }
}

void _set_num_threads_from_env() {
  char *num = getenv("OMP_NUM_THREADS");
  printf("Number of threads = %s from within C\n", num);
  omp_set_num_threads(atoi(num));
}
/*
*******************************************************************************
* Functions that modify C arrays whose address is passed from Perl in C
*******************************************************************************
*/

double*  simulate_array(int num_of_elements, int seed) {
  srand(seed); // Seed the random number generator
  double *array = (double *)malloc(num_of_elements * sizeof(double));
  for (int i = 0; i < num_of_elements; i++) {
    array[i] =
        (double)rand() / RAND_MAX; // Generate a random double between 0 and 1
  }
  return array;
}

void map_c_array(double *array, int len) {
  for (int i = 0; i < len; i++) {
    array[i] = cos(sin(sqrt(array[i])));
  }
}

void map_c_array_sequential(double* array, int len) {
  for (int i = 0; i < len; i++) {
    array[i] = sqrt(array[i]);
  }
  for (int i = 0; i < len; i++) {
    array[i] = sin(array[i]);
  }
  for (int i = 0; i < len; i++) {
    array[i] = cos(array[i]);
  }
}

void map_C_array_using_OMP(double* array, int len) {
#pragma omp parallel
  {
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = cos(sin(sqrt(array[i])));
    }
  }
}

void map_C_array_sequential_using_OMP(double* array, int len) {
#pragma omp parallel
  {
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = sqrt(array[i]);
    }
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = sin(array[i]);
    }
#pragma omp for schedule(runtime) nowait
    for (int i = 0; i < len; i++) {
      array[i] = cos(array[i]);
    }
  }
}

중요한 질문은 코드의 정확성을 위해 속도를 희생하는 빠른 부동 컴파일러 플래그를 사용하는 것이 성능에 영향을 미칠 수 있는지 여부입니다.
다음은 이 컴파일러 플래그가 없는 makefile입니다

CC = gcc
CFLAGS = -O3 -ftree-vectorize  -march=native  -Wall -std=gnu11 -fopenmp -fstrict-aliasing 
LDFLAGS = -fPIE -fopenmp
LIBS =  -lm

SOURCES = inplace_array_mod_with_OpenMP.c
OBJECTS = $(SOURCES:.c=_noffmath_gcc.o)
EXECUTABLE = inplace_array_mod_with_OpenMP_noffmath_gcc

all: $(SOURCES) $(EXECUTABLE)

clean:
    rm -f $(OBJECTS) $(EXECUTABLE)

$(EXECUTABLE): $(OBJECTS)
    $(CC) $(LDFLAGS) $(OBJECTS) $(LIBS) -o $@

%_noffmath_gcc.o : %.c 
    $(CC) $(CFLAGS) -c $< -o $@

이 플래그가 있는 항목은 다음과 같습니다.

CC = gcc
CFLAGS = -O3 -ftree-vectorize  -march=native -Wall -std=gnu11 -fopenmp -fstrict-aliasing -ffast-math
LDFLAGS = -fPIE -fopenmp
LIBS =  -lm

SOURCES = inplace_array_mod_with_OpenMP.c
OBJECTS = $(SOURCES:.c=_gcc.o)
EXECUTABLE = inplace_array_mod_with_OpenMP_gcc

all: $(SOURCES) $(EXECUTABLE)

clean:
    rm -f $(OBJECTS) $(EXECUTABLE)

$(EXECUTABLE): $(OBJECTS)
    $(CC) $(LDFLAGS) $(OBJECTS) $(LIBS) -o $@

%_gcc.o : %.c 
    $(CC) $(CFLAGS) -c $< -o $@

그리고 이 두 프로그램을 실행한 결과는 다음과 같습니다

  • -ffast-math 제외
OMP_SCHEDULE=guided,1 OMP_NUM_THREADS=8 ./inplace_array_mod_with_OpenMP_noffmath_gcc 50000000
Array size: 50000000
Schedule from env guided,1
Number of threads = 8 from within C
Non-sequential calculation time: 1.12 seconds
Sequential calculation time: 0.95 seconds
Parallel calculation using OMP time: 0.17 seconds
Sequential calculation using OMP time: 0.15 seconds
  • -ffast-math 사용
OMP_SCHEDULE=guided,1 OMP_NUM_THREADS=8 ./inplace_array_mod_with_OpenMP_gcc 50000000
Array size: 50000000
Schedule from env guided,1
Number of threads = 8 from within C
Non-sequential calculation time: 0.27 seconds
Sequential calculation time: 0.28 seconds
Parallel calculation using OMP time: 0.05 seconds
Sequential calculation using OMP time: 0.06 seconds

다음과 같이 Numba 코드에서 fastmath를 사용할 수 있습니다(기본값은 fastmath=False).

@njit(nogil=True,fastmath=True)
def compute_inplace_with_numba(array):
    np.sqrt(array,array)
    np.sin(array,array)
    np.cos(array,array)

주목할 만한 몇 가지 사항:

  • -ffast-math는 성능을 크게 향상시키지만(단일 스레드 및 다중 스레드 코드 모두에서 약 300%) 잘못된 결과를 생성할 수 있습니다
  • Fastmath는 Numba에서도 작동하지만 동일한 이유로 피해야 하며 정확성을 추구하는 모든 응용 프로그램에서는 피해야 합니다
  • 순차 C 단일 스레드 코드는 단일 스레드 PDL 및 Numpy와 유사한 성능을 제공합니다
  • 다소 놀랍게도 올바른(빠르지 않은) 수학을 사용하면 순차 코드가 비순차 코드보다 약 20% 더 빠릅니다.
  • 당연히 멀티 스레드 코드가 단일 스레드 코드보다 빠릅니다 :)
  • numbas가 어떻게 이 간단한 함수의 C 코드에 비해 50%의 성능 프리미엄을 제공하는지 아직 설명할 수 없습니다.

위 내용은 성능 탐구 3부: C Force의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.