관리 코드를 더 빠르게 작성: 리소스를 많이 사용하는 요소 파악

 

얀 그레이
Microsoft CLR 성능 팀

2003년 6월

적용 대상:
   Microsoft® .NET Framework

요약: 이 문서에서는 개발자가 더 나은 정보 코딩 결정을 내리고 더 빠른 코드를 작성할 수 있도록 측정된 작업 시간을 기준으로 관리 코드 실행 시간을 위한 낮은 수준의 비용 모델을 제공합니다. (인쇄된 페이지 30개)

CLR Profiler를 다운로드합니다. (330KB)

콘텐츠

소개(및 서약)
관리 코드에 대한 비용 모델
관리 코드의 비용
결론
리소스

소개(및 서약)

계산을 구현하는 방법은 무수히 많으며, 일부는 더 간단하고, 더 깨끗하고, 유지 관리하기 쉬운 다른 방법보다 훨씬 낫습니다. 어떤 방법은 타오르는 빠르고 일부는 놀랍게도 느립니다.

세계에서 느리고 뚱뚱한 코드를 저지르지 마십시오. 이러한 코드를 경멸하지 않습니까? 적합한 코드로 실행되고 시작됩니다. 시간에 초 동안 UI를 잠그는 코드? CPU를 페그하거나 디스크를 스래싱하는 코드

따라서 피해야 합니다. 대신, 일어서서 나와 함께 서약하십시오.

"나는 느린 코드를 제공하지 않을 것을 약속드립니다. 속도는 내가 관심있는 기능입니다. 매일 코드의 성능에 주의를 기울입니다. 나는 정기적으로 체계적으로 속도와 크기를 측정 합니다. 이 작업을 수행하는 데 필요한 도구를 배우거나 빌드하거나 구입합니다. 내 책임"이라고 말했다.

(정말) 그래서 당신은 약속했습니까? 잘했어.

그렇다면 가장 빠르고 타이트한 코드를 하루 종일 어떻게 작성할 수 있을까요? 그것은 사치스러운, 부풀어 오른 방법, 그리고 결과를 통해 생각하는 문제에 대한 선호도에서 검소한 방법을 의식적으로 선택하는 문제입니다. 코드의 지정된 페이지는 이러한 작은 결정의 수십 캡처합니다.

그러나 어떤 비용이 드는지 모르는 경우 대안 중에서 현명한 선택을 할 수 없습니다. 어떤 비용이 드는지 모르는 경우 효율적인 코드를 작성할 수 없습니다.

그것은 좋은 옛날에 더 쉬웠다. 좋은 C 프로그래머는 알고 있었다. C의 각 연산자 및 연산은 할당, 정수 또는 부동 소수점 수학, 역참조 또는 함수 호출과 같이 단일 기본 머신 작업에 더 많거나 적은 일대일로 매핑됩니다. True이면 올바른 피연산자를 올바른 레지스터에 배치하기 위해 몇 가지 컴퓨터 명령이 필요하고, 경우에 따라 단일 명령이 여러 C 작업(유명한 *dest++ = *src++;)을 캡처할 수 있지만 일반적으로 C 코드 줄을 작성(또는 읽기)하고 시간이 어디로 가는지 알 수 있습니다. 코드와 데이터 둘 다에 대해 C 컴파일러는 WYWIWYG입니다. "작성하는 것은 사용자가 얻을 수 있는 것입니다."입니다. (예외는 함수 호출이고, 입니다. 함수의 비용을 모르는 경우 제대로 알 수 없습니다.)

1990년대, 데이터 추상화, 개체 지향 프로그래밍 및 코드 재사용의 많은 소프트웨어 엔지니어링 및 생산성 이점을 누리기 위해 PC 소프트웨어 산업은 C에서 C++로 전환했습니다.

C++는 C의 상위 집합이며 "종량제"입니다. 새 기능은 사용하지 않으면 비용이 전혀 들지 않으므로 내부화된 비용 모델을 포함한 C 프로그래밍 전문 지식은 직접 적용할 수 있습니다. 일부 작업 C 코드를 사용하여 C++에 대해 다시 컴파일하는 경우 실행 시간과 공간 오버헤드는 크게 변경되지 않아야 합니다.

반면에 C++에서는 생성자, 소멸자, 신규, 삭제, 단일, 다중 및 가상 상속, 캐스트, 멤버 함수, 가상 함수, 오버로드된 연산자, 멤버에 대한 포인터, 개체 배열, 예외 처리 및 같은 컴퍼지션을 비롯한 많은 새로운 언어 기능을 도입하여 사소한 숨겨진 비용이 발생합니다. 예를 들어 가상 함수는 호출당 두 개의 추가 간접 참조 비용이 들며 각 instance 숨겨진 vtable 포인터 필드를 추가합니다. 또는 다음과 같은 무해한 코드가 있다고 생각해 보세요.

{ complex a, b, c, d; … a = b + c * d; }

는 약 13개의 암시적 멤버 함수 호출 (잘하면 인라인)으로 컴파일됩니다.

9 년 전 우리는 내 기사 C ++에서이 주제를 탐구 : 후드 아래. 나는 썼다 :

"프로그래밍 언어가 구현되는 방식을 이해하는 것이 중요합니다. 이러한 지식은 "컴파일러가 여기서 무엇을 하고 있는가?"에 대한 두려움과 경이로움을 해소합니다. 새 기능을 사용할 수 있는 자신감을 부여합니다. 및 는 다른 언어 기능을 디버깅하고 학습할 때 인사이트를 제공합니다. 또한 가장 효율적인 코드를 매일 작성하는 데 필요한 다양한 코딩 선택 항목의 상대적 비용에 대한 느낌을 줍니다."

이제 관리 코드를 비슷한 모양으로 살펴보겠습니다. 이 문서에서는 관리형 실행의 낮은 수준 시간과 공간 비용을 탐색하여 일상적인 코딩에서 더 스마트한 절충을 만들 수 있습니다 .

그리고 우리의 약속을 지키십시오.

관리 코드인 이유는 무엇인가요?

대부분의 네이티브 코드 개발자에게 관리 코드는 소프트웨어를 실행하는 더 좋고 생산적인 플랫폼입니다. 힙 손상 및 배열 인덱스 아웃 오브 바운드 오류와 같은 버그의 전체 범주를 제거하므로 심야 디버깅 세션이 좌절되는 경우가 많습니다. 안전한 모바일 코드(코드 액세스 보안을 통해) 및 XML 웹 서비스와 같은 최신 요구 사항을 지원하며, 노후화된 Win32/COM/ATL/MFC/VB에 비해 이 .NET Framework 더 적은 노력으로 더 많은 작업을 수행할 수 있는 상쾌한 클린 슬레이트 디자인입니다.

사용자 커뮤니티의 경우 관리 코드는 더 풍부하고 강력한 애플리케이션을 가능하게 하며, 더 나은 소프트웨어를 통해 더 나은 생활을 할 수 있습니다.

더 빠른 관리 코드를 작성하는 비밀은 무엇인가요?

더 적은 노력으로 더 많은 작업을 수행할 수 있다고 해서 현명하게 코딩할 책임을 포기할 수 있는 라이선스가 아닙니다. 첫째, 당신은 자신에게 그것을 인정해야합니다 : "나는 초보자입니다." 당신은 초보자입니다. 나도 초보자입니다. 우리는 모두 관리 코드 랜드의 아가씨입니다. 우리는 모두 여전히 밧줄을 배우고 있습니다 - 어떤 비용이 드는지 포함.

풍부하고 편리한 .NET Framework 관해서, 그것은 우리가 사탕 가게에서 아이들처럼. "와우, 난 모든 지루한 strncpy 물건을 할 필요가 없습니다, 난 그냥 함께 문자열을 '+'할 수 있습니다! 와우, 코드의 몇 줄에 XML의 메가 바이트를 로드 할 수 있습니다! 우후!"

그것은 모두 너무 쉽습니다. 너무 쉽게, 참으로. XML 인포셋을 구문 분석하는 RAM의 메가바이트를 쉽게 구울 수 있으므로 몇 가지 요소를 꺼내기만 하면 됩니다. C 또는 C++에서는 너무 고통스러웠기 때문에 두 번 생각할 수 있었고, SAX와 같은 API에서 상태 머신을 빌드할 수도 있습니다. .NET Framework 사용하여 전체 정보 세트를 한 gulp에 로드하면 됩니다. 어쩌면 당신은 그것을 반복해서 할 수도 있습니다. 그러면 애플리케이션이 더 이상 그렇게 빠르지 않을 수 있습니다. 어쩌면 그것은 많은 메가 바이트의 작업 세트가 있습니다. 어쩌면 당신은 그 쉬운 방법의 비용에 대해 두 번 생각했어야 ...

아쉽게도 현재 .NET Framework 설명서에서는 프레임워크 형식 및 메서드의 성능 영향을 자세히 설명하지 않으며 새 개체를 만들 수 있는 메서드도 지정하지 않습니다. 성능 모델링은 쉽게 다루거나 문서화할 수 있는 주제가 아닙니다. 하지만 여전히 "알 수 없음"으로 인해 정보에 입각한 결정을 내리는 것이 훨씬 더 어려워집니다.

우리는 모두 초보자이기 때문에 어떤 비용이 드는지 모르고 비용이 명확하게 문서화되지 않았기 때문에 무엇을해야합니까?

측정합니다. 비밀은 그것을 측정 하고 경계하는 것입니다. 우리 모두는 사물의 비용을 측정하는 습관에 들어가야 할 것입니다. 어떤 비용이 드는지 측정하는 데 어려움을 겪는다면, 우리는 실수로 비용이 드는 비용의 10배에 달하는 새로운 방법을 호출하지 않을 것입니다.

(그런데 BCL(기본 클래스 라이브러리) 또는 CLR 자체의 성능 기반에 대한 자세한 정보를 얻으려면 공유 원본 CLI(즉, Rotor)를 살펴보는 것이 좋습니다. 로터 코드는 .NET Framework 및 CLR과 블러드라인을 공유합니다. 전체적으로 동일한 코드는 아니지만, 로터에 대한 사려 깊은 연구를 통해 CLR의 내부적으로 진행되는 일에 대한 새로운 통찰력을 얻을 수 있음을 약속드립니다. 하지만 먼저 SSCLI 라이선스를 검토해야 합니다.)

지식

런던에서 택시 운전사가 되고 싶다면 먼저 지식을 얻어야 합니다. 학생들은 몇 달 동안 공부하여 런던의 수천 대의 작은 거리를 암기하고 장소에서 가장 좋은 경로를 배웁니다. 그리고 그들은 주위를 스카우트하고 자신의 책 학습을 강화하기 위해 스쿠터에 매일 외출.

마찬가지로 고성능 관리 코드 개발자가 되려면 관리 코드 지식을 획득해야 합니다. 각 하위 수준 운영 비용을 학습해야 합니다. 대리자 및 코드 액세스 보안 비용과 같은 기능을 배워야 합니다. 사용 중인 형식 및 메서드의 비용과 작성하는 방법을 학습해야 합니다. 또한 애플리케이션에 너무 많은 비용이 들 수 있는 메서드를 검색해도 상관없으므로 사용하지 마세요.

지식은 어떤 책에도 없습니다, 아아. 스쿠터를 타고 탐색해야 합니다 . 즉, csc, ildasm, VS.NET 디버거, CLR 프로파일러, 프로파일러, 일부 성능 타이머 등을 크랭크하고 시간과 공간에서 코드 비용이 어떻게 드는지 확인해야 합니다.

관리 코드에 대한 비용 모델

예비 항목을 제외하고 관리 코드에 대한 비용 모델을 고려해 보겠습니다. 이렇게 하면 리프 메서드를 살펴보고 어떤 식과 문이 비용이 더 많이 드는지 한눈에 알 수 있습니다. 새 코드를 작성할 때 더 스마트하게 선택할 수 있습니다.

(이렇게 하면 .NET Framework 메서드 또는 메서드를 호출하는 전이적 비용이 해결되지 않습니다. 다른 날에 또 다른 기사를 기다려야 합니다.)

앞에서 대부분의 C 비용 모델은 C++ 시나리오에서 계속 적용됩니다. 마찬가지로 대부분의 C/C++ 비용 모델은 여전히 관리 코드에 적용됩니다.

어떻게 할 수 있습니까? CLR 실행 모델을 알고 있습니다. 여러 언어 중 하나로 코드를 작성합니다. 어셈블리로 패키지된 CIL(공용 중간 언어) 형식으로 컴파일합니다. 기본 애플리케이션 어셈블리를 실행하고 CIL 실행을 시작합니다. 그러나 이전의 바이트 코드 인터프리터처럼 크기의 순서가 느려지지 않습니까?

Just-In-Time 컴파일러

이건 아니에요. CLR은 JIT(Just-In-Time) 컴파일러를 사용하여 CIL의 각 메서드를 네이티브 x86 코드로 컴파일한 다음 네이티브 코드를 실행합니다. 각 메서드가 처음 호출되면 JIT 컴파일이 약간 지연되지만 호출된 모든 메서드는 해석 오버헤드 없이 순수 네이티브 코드를 실행합니다.

기존의 오프라인 C++ 컴파일 프로세스와 달리 JIT 컴파일러에서 소요된 시간은 각 사용자의 얼굴에서 "벽시계 시간" 지연이므로 JIT 컴파일러에는 완전한 최적화 단계가 필요하지 않습니다. 그럼에도 불구하고 JIT 컴파일러가 수행하는 최적화 목록은 인상적입니다.

  • 상수 정리
  • 상수 및 복사 전파
  • 일반 하위 식 제거
  • 루프 고정의 코드 동작
  • 배달 못한 저장소 및 데드 코드 제거
  • 할당 등록
  • 메서드 인라인 처리
  • 루프 언롤링(작은 본문이 있는 작은 루프)

결과는 적어도 동일한 야구장에서 기존 네이티브 코드와 비슷합니다.

데이터에 관해서는 값 형식 또는 참조 형식의 혼합을 사용합니다. 정수 계열 형식, 부동 소수점 형식, 열거형 및 구조체를 포함한 값 형식은 일반적으로 스택에 상주합니다. 지역 주민과 구조체가 C/C++에 있는 것처럼 작고 빠릅니다. C/C++와 마찬가지로 복사 오버헤드가 엄청나게 많이 들 수 있으므로 큰 구조체를 메서드 인수 또는 반환 값으로 전달하지 않아야 합니다.

참조 형식 및 boxed 값 형식은 힙에 있습니다. C/C++의 개체 포인터와 마찬가지로 단순히 컴퓨터 포인터인 개체 참조로 처리됩니다.

따라서 jitted 관리 코드는 빠를 수 있습니다. 아래에서 설명하는 몇 가지 예외를 제외하고, 네이티브 C 코드에서 일부 식의 비용에 대한 직감이 있는 경우 관리 코드에서 동일한 비용으로 비용을 모델링하는 것은 그리 잘못되지 않습니다.

또한 "미리"가 CIL을 네이티브 코드 어셈블리로 컴파일하는 도구인 NGEN을 멘션 합니다. NGEN의 어셈블리는 현재 실행 시간에 상당한 영향을 미치지 않지만(양호하거나 나빠진) 많은 AppDomains 및 프로세스에 로드되는 공유 어셈블리에 대한 총 작업 집합을 줄일 수 있습니다. (OS는 모든 클라이언트에서 NGEN 코드의 복사본 하나를 공유할 수 있지만, jitted 코드는 일반적으로 AppDomains 또는 프로세스 간에 공유되지 않습니다. 그러나 을 LoaderOptimizationAttribute.MultiDomain참조하세요.)

Automatic Memory Management

관리 코드의 가장 중요한 출발(네이티브)은 자동 메모리 관리입니다. 새 개체를 할당하지만 CLR GC(가비지 수집기)는 연결할 수 없게 되면 자동으로 해제합니다. GC는 종종 눈에 띄지 않게 실행되며 일반적으로 애플리케이션을 밀리초 또는 2초 동안 중지합니다( 경우에 따라 더 긴 경우).

다른 여러 문서에서는 가비지 수집기의 성능에 미치는 영향에 대해 설명하며 여기서는 다시 캡슐화하지 않습니다. 애플리케이션이 이러한 다른 문서의 권장 사항을 따르는 경우 가비지 수집의 전체 비용은 중요하지 않을 수 있으며, 실행 시간의 몇 퍼센트는 경쟁력이 있거나 기존 C++ 개체 newdelete보다 우수할 수 있습니다. 개체를 만들고 나중에 자동으로 회수하는 분할 상환 비용은 충분히 낮아서 초당 수천만 개의 작은 개체를 만들 수 있습니다.

그러나 개체 할당은 여전히 무료가 아닙니다. 개체는 공간을 차지합니다. 만연한 개체 할당으로 인해 가비지 수집 주기가 더 자주 발생합니다.

훨씬 더 나쁜 것은 쓸모없는 개체 그래프에 대한 참조를 불필요하게 유지하는 것입니다. 우리는 때때로 애도 100 + MB 작업 세트와 겸손한 프로그램을 참조, 그 저자는 자신의 범법성을 거부하고 대신 관리 코드 자체와 일부 신비, 정체불명의 (따라서 난치성) 문제에 자신의 성능 저하를 특성. 그것은 비극적입니다. 그러나 CLR Profiler를 사용하여 한 시간 동안 연구하고 몇 줄의 코드로 변경하면 힙 사용량이 10배 이상 줄어듭니다. 큰 작업 집합 문제가 발생한 경우 첫 번째 단계는 미러 살펴보는 것입니다.

따라서 불필요하게 개체를 만들지 마세요. 자동 메모리 관리는 개체 할당 및 해제의 많은 복잡성, 번거로움 및 버그를 없애기 때문에 너무 빠르고 편리하기 때문에 나무에서 자라는 것처럼 자연스럽게 점점 더 많은 개체를 만드는 경향이 있습니다. 정말 빠른 관리 코드를 작성하려면 신중하게 적절하게 개체를 만듭니다.

이는 API 디자인에도 적용됩니다. 형식 및 해당 메서드를 디자인할 수 있으므로 클라이언트가 야생 중단을 사용하여 새 개체를 만들어야 합니다 . 그러지 마.

관리 코드의 비용

이제 다양한 하위 수준 관리 코드 작업의 시간 비용을 살펴보겠습니다.

표 1은 간단한 타이밍 루프 집합으로 수집된 Windows XP 및 .NET Framework v1.1("Everett")을 실행하는 정지 1.1GHz 펜티엄-III PC에서 나노초 단위로 다양한 하위 수준 관리 코드 작업의 대략적인 비용을 제공합니다.

테스트 드라이버는 각 테스트 메서드를 호출하여 수행할 반복 수를 지정하고, 50ms 이상에 대해 각 테스트를 수행하는 데 필요한 경우 218 ~2개의 30 회 반복을 반복하도록 자동으로 크기가 조정됩니다. 일반적으로 개체 할당을 많이 수행하는 테스트에서 0세대 가비지 수집의 여러 주기를 관찰할 수 있을 만큼 길어집니다. 이 표에는 평균 10회 이상의 시험 결과와 각 테스트 대상에 대한 최적(최소 시간) 평가판이 표시됩니다.

각 테스트 루프는 테스트 루프 오버헤드를 줄이기 위해 필요한 경우 4~64배로 제어되지 않습니다. 각 테스트에 대해 생성된 네이티브 코드를 검사하여 JIT 컴파일러가 테스트를 최적화하지 않았는지 확인했습니다. 예를 들어 테스트 루프 도중과 후에 중간 결과를 라이브 상태로 유지하기 위해 테스트를 수정한 경우가 있습니다. 마찬가지로 나는 여러 테스트에서 일반적인 subexpression 제거를 배제하기 위해 변경했습니다.

표 1 기본 시간(평균 및 최소)(ns)

평균 최소값 기본 유형 평균 최소값 기본 유형 평균 최소값 기본 유형
0.0 0.0 제어 2.6 2.6 new valtype L1 0.8 0.8 isinst up 1
1.0 1.0 Int add 4.6 4.6 new valtype L2 0.8 0.8 isinst down 0
1.0 1.0 Int 하위 6.4 6.4 새 valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 new valtype L4 10.7 10.6 isinst (up 2) down 1
35.9 35.7 Int div 23.0 22.9 new valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Int Shift 22.0 20.3 새 reftype L1 6.1 6.1 isinst down 3
2.1 2.1 long add 26.1 23.9 새 reftype L2 1.0 1.0 get 필드
2.1 2.1 long sub 30.2 27.5 새 reftype L3 1.2 1.2 prop 가져오기
34.2 34.1 long mul 34.1 30.8 새 reftype L4 1.2 1.2 필드 설정
50.1 50.0 long div 39.1 34.4 새 reftype L5 1.2 1.2 prop 설정
5.1 5.1 긴 시프트 22.3 20.3 새 reftype 빈 ctor L1 0.9 0.9 이 필드 가져오기
1.3 1.3 float add 26.5 23.9 새 reftype 빈 ctor L2 0.9 0.9 이 소품 가져오기
1.4 1.4 float 하위 38.1 34.7 새 reftype 빈 ctor L3 1.2 1.2 이 필드 설정
2.0 2.0 float mul 34.7 30.7 새 reftype 빈 ctor L4 1.2 1.2 이 prop 설정
27.7 27.6 float div 38.5 34.3 새 reftype 빈 ctor L5 6.4 6.3 가상 소품 가져오기
1.5 1.5 double add 22.9 20.7 새 reftype ctor L1 6.4 6.3 가상 prop 설정
1.5 1.5 double sub 27.8 25.4 새 reftype ctor L2 6.4 6.4 쓰기 장벽
2.1 2.0 double mul 32.7 29.9 새 reftype ctor L3 1.9 1.9 int 배열 elem 로드
27.7 27.6 double div 37.7 34.1 새 reftype ctor L4 1.9 1.9 store int array elem
0.2 0.2 인라인 정적 호출 43.2 39.1 새 reftype ctor L5 2.5 2.5 load obj array elem
6.1 6.1 정적 호출 28.6 26.7 새 reftype ctor no-inl L1 16.0 16.0 store obj array elem
1.1 1.0 인라인 instance 호출 38.9 36.5 새 reftype ctor no-inl L2 29.0 21.6 box int
6.8 6.8 instance 호출 50.6 47.7 새 reftype ctor no-inl L3 3.0 3.0 unbox int
0.2 0.2 inlined 이 inst call 61.8 58.2 새 reftype ctor no-inl L4 41.1 40.9 대리자 호출
6.2 6.2 이 instance 호출 72.6 68.5 새 reftype ctor no-inl L5 2.7 2.7 sum 배열 1000
5.4 5.4 가상 호출 0.4 0.4 cast up 1 2.8 2.8 sum 배열 10000
5.4 5.4 이 가상 호출 0.3 0.3 cast down 0 2.9 2.8 sum 배열 100000
6.6 6.5 인터페이스 호출 8.9 8.8 cast down 1 5.6 5.6 sum 배열 1000000
1.1 1.0 inst itf instance 호출 9.8 9.7 cast (up 2) down 1 3.5 3.5 합계 목록 1000
0.2 0.2 이 itf instance 호출 8.9 8.8 cast down 2 6.1 6.1 합계 목록 10000
5.4 5.4 inst itf 가상 호출 8.7 8.6 cast down 3 22.0 22.0 sum list 100000
5.4 5.4 이 itf 가상 호출       21.5 21.4 합계 목록 1000000

고지 사항: 이 데이터를 너무 문자 그대로 사용하지 마세요. 시간 테스트는 예기치 않은 두 번째 순서 효과의 위험으로 내포되어 있습니다. 우연한 일이 발생하면 코드가 캐시 라인에 걸쳐 있거나, 다른 것을 방해하거나, 무엇이 있는지를 방해할 수 있도록 jitted 코드 또는 몇 가지 중요한 데이터를 배치할 수 있습니다. 1나노초 정도의 시간과 시간 차이는 관찰 가능한 한계에 있습니다.

또 다른 고지 사항: 이 데이터는 캐시에 완전히 맞는 작은 코드 및 데이터 시나리오에만 적합합니다. 애플리케이션의 "핫" 부분이 온칩 캐시에 맞지 않는 경우 다른 성능 문제가 있을 수 있습니다. 우리는 종이의 끝 부분에 캐시에 대해 할 말이 훨씬 더 있습니다.

또 다른 고지 사항: CIL의 어셈블리로 구성 요소 및 애플리케이션을 배송할 때의 숭고한 이점 중 하나는 프로그램이 자동으로 매 초마다 더 빨라지고 매년 더 빨리 얻을 수 있다는 것입니다. 런타임이 (이론적으로) 프로그램이 실행될 때 JIT 컴파일 코드를 다시 조정할 수 있기 때문입니다. 런타임의 새 릴리스마다 더 낫고, 더 똑똑하고, 더 빠른 알고리즘은 코드를 최적화할 때 새로운 찌를 수 있기 때문에 "매년 더 빨라집니다." 따라서 이러한 타이밍 중 몇 가지가 .NET 1.1에서 최적이 아닌 것처럼 보이는 경우 제품의 후속 릴리스에서 개선해야 한다는 점을 주의해야 합니다. 이 문서에 보고된 지정된 코드 네이티브 코드 시퀀스는 향후 .NET Framework 릴리스에서 변경될 수 있습니다.

고지 사항을 제쳐두고, 데이터는 다양한 원형의 현재 성능에 대한 합리적인 직감 느낌을 제공합니다. 숫자는 의미가 있으며, 컴파일된 네이티브 코드와 마찬가지로 대부분의 jitted 관리 코드가 "컴퓨터에 가깝게" 실행된다는 내 어설션을 입증합니다. 기본 정수 및 부동 연산은 빠르며 다양한 종류의 메서드 호출은 더 적지만(신뢰) 여전히 네이티브 C/C++와 비슷합니다. 네이티브 코드(캐스트, 배열 및 필드 저장소, 함수 포인터(대리자))에서 일반적으로 저렴한 일부 작업도 더 비싸다는 것을 알 수 있습니다. 그 이유는 확인해 보겠습니다.

산술 연산

표 2 산술 연산 시간(ns)

평균 최소값 기본 유형 평균 최소값 기본 유형
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 float 하위
2.7 2.7 int mul 2.0 2.0 float mul
35.9 35.7 int div 27.7 27.6 float div
2.1 2.1 int shift      
2.1 2.1 long add 1.5 1.5 double add
2.1 2.1 long sub 1.5 1.5 double sub
34.2 34.1 long mul 2.1 2.0 double mul
50.1 50.0 long div 27.7 27.6 double div
5.1 5.1 긴 시프트      

옛날에 부동 소수점 수학은 아마도 정수 수학보다 느린 크기의 순서였을 것입니다. 표 2에서 알 수 있듯이 최신 파이프라인 부동 소수점 단위를 사용하면 차이가 거의 없거나 전혀 없는 것처럼 보입니다. 평균 노트북 PC가 이제 기가플롭 클래스 컴퓨터(캐시에 맞는 문제)라고 생각하면 놀랍습니다.

정수 및 부동 소수점 추가 테스트에서 jitted 코드 줄을 살펴보겠습니다.

디스어셈블리 1 Int 추가 및 부동 소수 추가

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

여기서는 jitted 코드가 최적에 근접한 것을 볼 수 있습니다. 이 int add 경우 컴파일러는 5개의 지역 변수를 등록하기도 했습니다. float add 사례에서는 일반적인 하위 식 제거를 무산하기 위해 클래스 정적을 통해 h 변수 a 를 만들어야했습니다.

메서드 호출

이 섹션에서는 메서드 호출의 비용 및 구현을 살펴봅니다. 테스트 주체는 다양한 종류의 메서드가 있는 인터페이스 I를 구현하는 클래스 T 입니다. 목록 1을 참조하세요.

1 메서드 호출 테스트 메서드 나열

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

표 3을 고려합니다. 첫 번째 근사값에 메서드가 인라인(추상화 비용이 전혀 들지 않음) 또는 그렇지 않은 것으로 나타납니다(추상화 비용은 >정수 작업 5배). 정적 호출, instance 호출, 가상 호출 또는 인터페이스 호출의 원시 비용에는 큰 차이가 없는 것으로 보입니다.

표 3 메서드 호출 시간(ns)

평균 최소값 기본 유형 호출 수신자 평균 최소값 기본 유형 호출 수신자
0.2 0.2 인라인 정적 호출 inl_s1 5.4 5.4 가상 호출 v1
6.1 6.1 정적 호출 s1 5.4 5.4 이 가상 호출 v1
1.1 1.0 인라인 instance 호출 inl_i1 6.6 6.5 인터페이스 호출 itf1
6.8 6.8 instance 통화 i1 1.1 1.0 inst itf instance 호출 itf1
0.2 0.2 inlined this inst call inl_i1 0.2 0.2 이 itf instance 호출 itf1
6.2 6.2 이 instance 호출 i1 5.4 5.4 inst itf 가상 호출 itf5
        5.4 5.4 이 itf 가상 호출 itf5

그러나 이러한 결과는 대표적이지 않은 최상의 경우이며, 타이트한 타이밍을 실행하는 효과는 수백만 번 반복됩니다. 이러한 테스트 사례에서 가상 및 인터페이스 메서드 호출 사이트는 모노모픽(예: 호출 사이트당 대상 메서드는 시간이 지남에 따라 변경되지 않음)이므로 가상 메서드와 인터페이스 메서드 디스패치 메커니즘(메서드 테이블 및 인터페이스 맵 포인터 및 항목)을 캐싱하고 극적으로 제공된 분기 예측을 조합하면 프로세서가 예측하기 어려운 이러한 작업을 통해 비현실적으로 효과적인 작업을 호출할 수 있습니다. 데이터 종속 분기. 실제로 디스패치 메커니즘 데이터 또는 분기 잘못된 예측(필수 용량 누락 또는 다형 호출 사이트)에 대한 데이터 캐시 누락은 가상 및 인터페이스 호출의 속도를 수십 주기로 늦출 수 있습니다.

이러한 각 메서드 호출 시간을 자세히 살펴보겠습니다.

첫 번째 경우 인 라인 정적 호출 에서는 일련의 빈 정적 메서드 등을 호출합니다 s1_inl() . 컴파일러는 모든 호출을 완전히 인라인화하므로 빈 루프의 타이밍을 지정합니다.

정적 메서드 호출의 대략적인 비용을 측정하기 위해 정적 메서드 s1() 등을 너무 크게 만들어 호출자에 인라인하는 것이 수익성이 없습니다.

명시적 false 조건자 변수 falsePred를 사용해야 하는 경우도 확인합니다. 우리가 쓴 경우

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

JIT 컴파일러는 이전과 같이 전체(이제 비어 있음) 메서드 본문에 dummy 대한 배달 못한 호출을 제거하고 인라인합니다. 그런데 여기서 호출 시간의 6.1 ns 중 일부는 (false) 조건자 테스트에 기인하고 호출된 정적 메서드 s1내에서 이동해야 합니다. (그런데 인라인을 사용하지 않도록 설정하는 더 좋은 방법은 특성입니다 CompilerServices.MethodImpl(MethodImplOptions.NoInlining) .)

인라인된 instance 호출 및 정기적인 instance 호출 타이밍에 동일한 접근 방식이 사용되었습니다. 그러나 C# 언어 사양은 null 개체 참조에 대한 모든 호출이 NullReferenceException을 throw하도록 보장하므로 모든 호출 사이트는 instance null이 아닌지 확인해야 합니다. 이 작업은 instance 참조를 역참조하여 수행합니다. null면 이 예외로 전환된 오류를 생성합니다.

디스어셈블리 2에서는 지역 변수를 사용할 때 정적 변수 t 를 instance 사용합니다.

    T t = new T();

컴파일러가 루프에서 null instance 검사 게양했습니다.

"검사"instance null이 있는 디스어셈블리 2 인스턴스 메서드 호출 사이트

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

이 instance 호출이 instance 호출의 경우는 instance this제외하면 동일합니다. 여기서 null 검사 해제되었습니다.

디스어셈블리 3 이 instance 메서드 호출 사이트

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

가상 메서드 호출 은 기존 C++ 구현에서와 마찬가지로 작동합니다. 새로 도입된 각 가상 메서드의 주소는 형식의 메서드 테이블에 있는 새 슬롯 내에 저장됩니다. 각 파생 형식의 메서드 테이블은 해당 기본 형식의 메서드 테이블을 준수하고 확장하며, 모든 가상 메서드 재정의는 기본 형식의 가상 메서드 주소를 파생 형식의 메서드 테이블에 있는 해당 슬롯에 있는 파생 형식의 가상 메서드 주소로 대체합니다.

호출 사이트에서 가상 메서드 호출은 instance 호출에 비해 두 개의 추가 로드가 발생합니다. 하나는 메서드 테이블 주소(항상 에 *(this+0)있음)를 가져오고 다른 하나는 메서드 테이블에서 적절한 가상 메서드 주소를 가져와 호출합니다. 디스어셈블리 4를 참조하세요.

디스어셈블리 4 가상 메서드 호출 사이트

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

마지막으로 인터페이스 메서드 호출 (디스어셈블리 5)에 대해 살펴보겠습니다. C++에 정확히 해당하는 항목은 없습니다. 지정된 형식은 임의의 수의 인터페이스를 구현할 수 있으며 각 인터페이스에는 논리적으로 고유한 메서드 테이블이 필요합니다. 인터페이스 메서드를 디스패치하려면 메서드 테이블, 해당 인터페이스 맵, 해당 맵의 인터페이스 항목을 조회한 다음, 메서드 테이블의 인터페이스 섹션에 있는 적절한 항목을 통해 간접적으로 호출합니다.

디스어셈블리 5 인터페이스 메서드 호출 사이트

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

기본 타이밍의 나머지 부분에서는 itf instance 호출, 이 itf instance 호출, inst itf 가상 호출, 이 itf 가상 호출은 파생 형식의 메서드가 인터페이스 메서드를 구현할 때마다 instance 메서드 호출 사이트를 통해 호출 가능한 상태로 유지된다는 생각을 강조합니다.

예를 들어 이 itf instance 호출, instance(인터페이스 아님) 참조를 통한 인터페이스 메서드 구현에 대한 호출 테스트의 경우 인터페이스 메서드가 성공적으로 인라인화되고 비용이 0 ns로 이동합니다. 인터페이스 메서드 구현도 instance 메서드로 호출할 때 인라인화할 수 있습니다.

메서드에 대한 호출 아직 Jitt가 되지 않습니다.

정적 및 instance 메서드 호출(가상 및 인터페이스 메서드 호출은 아님)의 경우 JIT 컴파일러는 현재 해당 호출 사이트가 jitted될 때까지 대상 메서드가 이미 jitted되었는지 여부에 따라 다른 메서드 호출 시퀀스를 생성합니다.

호출 수신자(대상 메서드)가 아직 jitt되지 않은 경우 컴파일러는 "prejit 스텁"으로 처음 초기화된 포인터를 통해 간접 호출을 내보냅니다. 대상 메서드에 대한 첫 번째 호출은 스텁에 도착하여 메서드의 JIT 컴파일을 트리거하고, 네이티브 코드를 생성하고, 포인터를 업데이트하여 새 네이티브 코드를 처리합니다.

호출 수신자가 이미 jitted된 경우 컴파일러가 직접 호출을 내보내도록 네이티브 코드 주소를 알 수 있습니다.

새 개체 만들기

새 개체 만들기는 개체 할당 및 개체 초기화의 두 단계로 구성됩니다.

참조 형식의 경우 개체는 가비지 수집 힙에 할당됩니다. 스택 상주 또는 다른 참조 또는 값 형식 내에 포함된 값 형식의 경우 값 형식 개체는 바깥쪽 구조체의 상수 오프셋에서 찾을 수 있으며 할당이 필요하지 않습니다.

일반적인 작은 참조 형식 개체의 경우 힙 할당이 매우 빠릅니다. 고정된 개체가 있는 경우를 제외하고 각 가비지 수집 후에는 0세대 힙의 라이브 개체가 압축되어 1세대로 승격되므로 메모리 할당자에는 작업할 수 있는 큰 연속된 사용 가능한 메모리 경기장이 있습니다. 대부분의 개체 할당은 포인터 증가 및 범위 검사 발생하며 이는 일반적인 C/C++ 자유 목록 할당자(malloc/operator new)보다 저렴합니다. 가비지 수집기는 컴퓨터의 캐시 크기를 고려하여 0세대 개체를 캐시/메모리 계층 구조의 빠른 스윗 스팟에 유지하려고 시도합니다.

기본 관리 코드 스타일은 수명이 짧은 대부분의 개체를 할당하고 신속하게 회수하는 것이므로 이러한 새 개체의 가비지 수집에 대한 분할 상환 비용도 포함됩니다.

가비지 수집기는 죽은 물체를 애도하는 데 시간을 할애하지 않습니다. 개체가 죽은 경우 GC는 개체를 보지 못하고, 걷지 않으며, 나노초의 생각을 주지 않습니다. GC는 생활의 복지에만 관심이 있습니다.

(예외: 종료 가능한 데드 개체는 특별한 경우입니다. GC는 이러한 개체를 추적하고, 종료 가능한 데드 개체를 차세대 보류 중인 종료로 특별히 승격합니다. 이는 비용이 많이 들며, 최악의 경우 대형 데드 개체 그래프를 전이적으로 승격할 수 있습니다. 따라서 엄밀히 필요한 경우가 아니면 개체를 종료할 수 없도록 합니다. 그리고 해야 하는 경우 가능한 경우 를 호출 GC.SuppressFinalizer 하여 Dispose 패턴을 사용하는 것이 좋습니다.) 메서드에 Finalize 필요한 경우가 아니면 종료 가능한 개체에서 다른 개체에 대한 참조를 보유하지 마세요.

물론, 큰 단기 개체의 분할 상환 GC 비용은 작은 수명이 짧은 개체의 비용보다 큽니다. 각 개체 할당은 다음 가비지 수집 주기에 훨씬 더 가까워집니다. 더 큰 개체는 작은 개체를 훨씬 더 빨리 수행합니다. 더 빨리 (또는 나중에), 계산의 순간이 올 것이다. GC 주기, 특히 0세대 컬렉션은 매우 빠르지만 대부분의 새 개체가 죽은 경우에도 무료가 아닙니다. 라이브 개체를 찾아(표시)하려면 먼저 스레드를 일시 중지한 다음 스택 및 기타 데이터 구조를 연습하여 루트 개체 참조를 힙으로 수집해야 합니다.

(더 중요한 것은 작은 개체와 동일한 양의 캐시에 맞는 더 큰 개체가 적다는 것입니다. 캐시 누락 효과는 코드 경로 길이 효과를 쉽게 지배할 수 있습니다.)

개체에 대한 공간이 할당되면 개체를 초기화(생성)하는 데 남아 있습니다. CLR은 모든 개체 참조가 null로 미리 초기화되고 모든 기본 스칼라 형식이 0, 0.0, false 등으로 초기화되도록 보장합니다. 따라서 사용자 정의 생성자에서 중복으로 수행할 필요가 없습니다. 물론 자유롭게 느껴보시기 바랍니다. 그러나 JIT 컴파일러가 현재 반드시 중복 저장소를 최적화하지는 않습니다.)

CLR은 instance 필드를 제외하는 것 외에도 개체의 내부 구현 필드인 메서드 테이블 포인터와 메서드 테이블 포인터 앞에 오는 개체 헤더 단어를 초기화합니다(참조 형식만 해당). 배열에는 Length 필드도 있고 개체 배열은 Length 및 요소 형식 필드를 가져옵니다.

그런 다음 CLR은 개체의 생성자(있는 경우)를 호출합니다. 사용자 정의 또는 컴파일러가 생성되었는지 여부에 관계없이 각 형식의 생성자는 먼저 기본 형식의 생성자를 호출한 다음 사용자 정의 초기화를 실행합니다(있는 경우).

이론적으로 이것은 심층 상속 시나리오에 비용이 많이 들 수 있습니다. E가 D를 확장하면 C 확장 B는 A를 확장(System.Object 확장)한 다음 E를 초기화하면 항상 5개의 메서드 호출이 발생합니다. 실제로는 컴파일러가 빈 기본 형식 생성자에 대한 호출을 멀리(아무 것도 없음으로) 인라인으로 표시하기 때문에 상황이 그렇게 나쁘지 않습니다.

표 4의 첫 번째 열을 참조하여 약 8개의 int-add-times에서 4개의 int 필드가 있는 구조체 D 를 만들고 초기화할 수 있는지 확인합니다. 디스어셈블리 6은 A, C 및 E를 만드는 세 가지 타이밍 루프에서 생성된 코드입니다. (각 루프 내에서 각 새 instance 수정하여 JIT 컴파일러가 모든 항목을 최적화하지 못하게 합니다.)

표 4 값 및 참조 형식 개체 생성 시간(ns)

평균 최소값 기본 유형 평균 최소값 기본 유형 평균 최소값 기본 유형
2.6 2.6 new valtype L1 22.0 20.3 새 reftype L1 22.9 20.7 새 rt ctor L1
4.6 4.6 new valtype L2 26.1 23.9 새 reftype L2 27.8 25.4 새 rt ctor L2
6.4 6.4 새 valtype L3 30.2 27.5 새 reftype L3 32.7 29.9 새 rt ctor L3
8.0 8.0 new valtype L4 34.1 30.8 새 reftype L4 37.7 34.1 새 rt ctor L4
23.0 22.9 new valtype L5 39.1 34.4 새 reftype L5 43.2 39.1 새 rt ctor L5
      22.3 20.3 new rt empty ctor L1 28.6 26.7 new rt no-inl L1
      26.5 23.9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

디스어셈블리 6 값 형식 개체 생성

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

다음 다섯 타이밍 (새로운 reftype L1, ... new reftype L5)는 5가지 상속 수준의 참조 형식 A인 ..., E, sans 사용자 정의 생성자에 대한 것입니다.

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

참조 형식 시간을 값 형식 시간과 비교하면 각 instance 분할 상환 할당 및 해제 비용은 테스트 머신에서 약 20 ns(20X int 추가 시간)입니다. 이는 초당 약 5천만 개의 수명이 짧은 개체를 할당, 초기화 및 회수하는 빠른 속도입니다. 5개 필드만큼 작은 개체의 경우 할당 및 컬렉션은 개체 생성 시간의 절반만 차지합니다. 디스어셈블리 7을 참조하세요.

디스어셈블리 7 참조 형식 개체 생성

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

5개의 타이밍의 마지막 세 세트는 이 상속된 클래스 생성 시나리오에 대한 변형을 제공합니다.

  1. 새 rt empty ctor L1, ..., new rt empty ctor L5: 각 형식 A, ... E 에는 빈 사용자 정의 생성자가 있습니다. 이러한 코드는 모두 인라인 처리되며 생성된 코드는 위의 코드와 동일합니다.

  2. 새 rt ctor L1, ..., 새 rt ctor L5: 각 형식 A, ... E 에는 instance 변수를 1로 설정하는 사용자 정의 생성자가 있습니다.

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

컴파일러는 중첩된 기본 클래스 생성자 호출의 각 집합을 사이트에 인라인합니다 new . (디스어셈블리 8).

디스어셈블리 8 깊이 인라인 상속된 생성자

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. 새 rt no-inl L1, ..., new rt no-inl L5: 각 형식 A, ... E 에는 인라인에 비해 비용이 너무 많이 들도록 의도적으로 작성된 사용자 정의 생성자가 있습니다. 이 시나리오는 심층 상속 계층 구조 및 래그시 생성자를 사용하여 복잡한 개체를 만드는 비용을 시뮬레이션합니다.

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

표 4의 마지막 5가지 타이밍은 중첩된 기본 생성자를 호출하는 추가 오버헤드를 보여 줍니다.

Interlude: CLR Profiler 데모

이제 CLR Profiler의 빠른 데모를 참조하세요. 이전에 할당 프로파일러로 알려진 CLR Profiler는 CLR 프로파일링 API를 사용하여 애플리케이션이 실행됨에 따라 이벤트 데이터, 특히 호출, 반환 및 개체 할당 및 가비지 수집 이벤트를 수집합니다. (CLR Profiler는 "침습적" 프로파일러이므로 프로파일된 애플리케이션의 속도가 상당히 느려집니다.) 이벤트가 수집된 후 CLR Profiler를 사용하여 계층적 호출 그래프와 메모리 할당 패턴 간의 상호 작용을 포함하여 애플리케이션의 메모리 할당 및 GC 동작을 탐색합니다.

CLR Profiler는 많은 "성능 문제가 있는" 관리 코드 애플리케이션의 경우 데이터 할당 프로필을 이해하면 작업 집합을 줄이는 데 필요한 중요한 인사이트를 제공하므로 빠르고 검소한 구성 요소와 애플리케이션을 제공하기 때문에 학습할 가치가 있습니다.

CLR Profiler는 예상보다 더 많은 스토리지를 할당하는 메서드를 표시할 수도 있으며, GC에서 회수할 수 있는 쓸모 없는 개체 그래프에 대한 참조를 실수로 유지하는 경우를 발견할 수 있습니다. (일반적인 문제 디자인 패턴은 더 이상 필요하지 않거나 나중에 재구성하기에 안전한 항목의 소프트웨어 캐시 또는 조회 테이블입니다. 캐시가 개체 그래프를 생생하게 유지하면 비극적입니다. 대신 더 이상 필요하지 않은 개체에 대한 참조를 null로 표시해야 합니다.)

그림 1은 타이밍 테스트 드라이버를 실행하는 동안 힙의 타임라인 보기입니다. 톱니 패턴은 수천 개의 개체 C 인스턴스(magenta), (자주색) DE (파란색)의 할당을 나타냅니다. 몇 밀리초마다 새 개체(0세대) 힙에서 최대 150KB의 RAM을 씹고, 가비지 수집기는 잠시 실행하여 재활용하고 라이브 개체를 1세대로 승격합니다. 이 침습적(느린) 프로파일링 환경에서도 100ms(2.8초에서 2.9초)의 간격으로 최대 8세대 0GC 주기를 거치는 것은 놀라운 일입니다. 그런 다음 2.977 s에서 다른 E instance 위한 공간을 만드는 가비지 수집기는 1세대 힙을 수집하고 압축하는 1세대 가비지 수집을 수행하므로 톱니의 시작 주소가 더 낮은 시작 주소에서 계속됩니다.

그림1 CLR 프로파일러 시간선 보기

개체가 클수록(C보다 큰 D보다 크면) 0세대 힙이 더 빨리 채워지고 GC 주기가 더 빈번해집니다.

캐스트 및 인스턴스 유형 검사

안전하고 안전하며 검증 가능한 관리 코드의 기반은 형식 안전성입니다. 개체를 형식이 아닌 형식으로 캐스팅할 수 있다면 CLR의 무결성을 손상시키고 신뢰할 수 없는 코드의 자비를 베푸는 것이 간단합니다.

표 5 캐스트 및 isinst Times(ns)

평균 최소값 기본 유형 평균 최소값 기본 유형
0.4 0.4 cast up 1 0.8 0.8 isinst up 1
0.3 0.3 cast down 0 0.8 0.8 isinst down 0
8.9 8.8 cast down 1 6.3 6.3 isinst down 1
9.8 9.7 cast (up 2) down 1 10.7 10.6 isinst (up 2) down 1
8.9 8.8 cast down 2 6.4 6.4 isinst down 2
8.7 8.6 cast down 3 6.1 6.1 isinst down 3

표 5에는 이러한 필수 형식 검사의 오버헤드가 표시됩니다. 파생 형식에서 기본 형식으로 캐스팅하는 것은 항상 안전하며 무료입니다. 반면 기본 형식에서 파생 형식으로의 캐스트는 형식을 선택해야 합니다.

(선택) 캐스트는 개체 참조를 대상 형식으로 변환하거나 을 throw합니다 InvalidCastException.

반면 CIL isinst 명령은 C# as 키워드(keyword) 구현하는 데 사용됩니다.

bac = ac as B;

이 아니거나 에서 B파생된 경우 ac 결과는 예외가 아닌 입니다null.B

목록 2는 캐스트 타이밍 루프 중 하나를 보여 줍니다. 디스어셈블리 9는 파생 형식으로 캐스팅된 하나의 생성된 코드를 보여 줍니다. 캐스트를 수행하기 위해 컴파일러는 도우미 루틴에 대한 직접 호출을 내보낸다.

캐스트 타이밍을 테스트하는 2 루프 나열

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

디스어셈블리 9 다운 캐스트

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

속성

관리 코드에서 속성은 개체의 필드처럼 작동하는 메서드 쌍, 속성 getter 및 속성 setter입니다. get_ 메서드는 속성을 페치합니다. set_ 메서드는 속성을 새 값으로 업데이트합니다.

그 외에 속성은 일반적인 instance 메서드 및 가상 메서드와 마찬가지로 동작하고 비용이 듭니다. 속성을 사용하여 instance 필드를 가져오거나 저장하는 경우 일반적으로 작은 메서드와 마찬가지로 인라인으로 표시됩니다.

표 6에서는 필드 및 속성을 instance 정수 집합을 가져오고 추가 및 저장하는 데 필요한 시간을 보여 있습니다. 속성을 가져오거나 설정하는 비용은 속성이 가상으로 선언 되지 않는 한 기본 필드에 대한 직접 액세스와 실제로 동일합니다. 이 경우 비용은 가상 메서드 호출의 대략적인 비용입니다. 놀랄 일이 아닙니다.

표 6 필드 및 속성 시간(ns)

평균 최소값 기본 유형
1.0 1.0 get 필드
1.2 1.2 get prop
1.2 1.2 set 필드
1.2 1.2 prop 설정
6.4 6.3 가상 소품 가져오기
6.4 6.3 가상 prop 설정

쓰기 장벽

CLR 가비지 수집기는 수집 오버헤드를 최소화하기 위해 "세대 가설"(대부분의 새 개체가 젊은 개체)을 잘 활용합니다.

힙은 논리적으로 세대로 분할됩니다. 최신 개체는 0세대(0세대)에 있습니다. 이러한 개체는 아직 컬렉션에서 살아남지 못했습니다. 0세대 컬렉션 중에 GC는 머신 레지스터, 스택, 클래스 정적 필드 개체 참조 등의 개체 참조를 포함하는 GC 루트 집합에서 연결할 수 있는 0세대 개체를 결정합니다. 전이적으로 연결할 수 있는 개체는 "라이브"이며 1세대로 승격(복사)됩니다.

총 힙 크기는 수백MB일 수 있지만 0세대 힙 크기는 256KB일 수 있으므로 GC의 개체 그래프 추적 범위를 0세대 힙으로 제한하는 것은 CLR의 매우 짧은 컬렉션 일시 중지 시간을 달성하는 데 필수적인 최적화입니다.

그러나 1세대 또는 2세대 개체의 개체 참조 필드에 0세대 개체에 대한 참조를 저장할 수 있습니다. Gen 0 컬렉션 중에는 1세대 또는 2세대 개체를 검사하지 않으므로 지정된 0세대 개체에 대한 유일한 참조인 경우 GC에서 해당 개체를 잘못 회수할 수 있습니다. 우리는 그런 일이 일어나게 할 수 없습니다!

대신 힙의 모든 개체 참조 필드에 대한 모든 저장소에는 쓰기 장벽이 발생합니다. 이는 새 세대 개체 참조의 저장소를 이전 세대 개체의 필드에 효율적으로 기록해 주는 부기 코드입니다. 이러한 이전 개체 참조 필드는 후속 GC의 GC 루트 집합에 추가됩니다.

개체별 참조-필드 저장소 쓰기 장벽 오버헤드는 간단한 메서드 호출 비용과 비슷합니다(표 7). 네이티브 C/C++ 코드에 없는 새로운 비용이지만 일반적으로 초고속 개체 할당 및 GC에 대한 비용을 지불하는 것은 작은 가격이며 자동 메모리 관리의 많은 생산성 이점입니다.

표 7 쓰기 장벽 시간(ns)

평균 최소값 기본 유형
6.4 6.4 쓰기 장벽

쓰기 장벽은 좁은 내부 루프에서 비용이 많이 들 수 있습니다. 그러나 앞으로 몇 년 동안 우리는 취한 쓰기 장벽의 수와 총 상각 비용을 줄이는 고급 컴파일 기술을 기대할 수 있습니다.

참조 형식의 개체 참조 필드에 대한 저장소에서만 쓰기 장벽이 필요하다고 생각할 수 있습니다. 그러나 값 형식 메서드 내에서 는 개체 참조 필드에 저장(있는 경우)도 쓰기 장벽으로 보호됩니다. 값 형식 자체가 때때로 힙에 있는 참조 형식 내에 포함될 수 있기 때문에 이 작업이 필요합니다.

Array 요소 액세스

배열 범위를 벗어난 오류 및 힙 손상을 진단하고 배제하고 CLR 자체의 무결성을 보호하기 위해 배열 요소 로드 및 저장소가 경계를 검사하여 인덱스가 간격 [0,array) 내에 있는지 확인합니다. Length-1] 포함 또는 throw.IndexOutOfRangeException

테스트는 배열 및 배열의 int[] 요소를 로드하거나 저장하는 시간을 측정합니다 A[] . (표 8).

표 8 배열 액세스 시간(ns)

평균 최소값 기본 유형
1.9 1.9 int array elem 로드
1.9 1.9 store int array elem
2.5 2.5 load obj array elem
16.0 16.0 store obj array elem

범위 검사 배열 인덱스를 암시적 배열과 비교해야 합니다. 길이 필드입니다. 디스어셈블리 10에서 보여 주듯이 두 가지 지침에서 인덱스가 0보다 작거나 배열보다 크거나 같지 않은지 검사. Length -이면 예외를 throw하는 줄 바깥의 시퀀스로 분기합니다. 개체 배열 요소의 로드 및 int 및 기타 간단한 값 형식의 배열에 대한 저장소의 경우에도 마찬가지입니다. (Load obj array elem time은 내부 루프의 약간의 차이로 인해 (매우) 느립니다.)

디스어셈블리 10 int 배열 요소 로드

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

JIT 컴파일러는 코드 품질 최적화를 통해 중복 경계 검사를 제거하는 경우가 많습니다.

이전 섹션을 떠올리면 개체 배열 요소 저장소 의 비용이 훨씬 더 많이 들 것으로 예상할 수 있습니다. 개체 참조를 개체 참조 배열에 저장하려면 런타임에서 다음을 수행해야 합니다.

  1. 검사 배열 인덱스가 경계에 있습니다.
  2. 검사 개체는 배열 요소 형식의 instance.
  3. 쓰기 장벽을 수행합니다(배열에서 개체로의 세대 간 개체 참조를 표시).

이 코드 시퀀스는 다소 깁니다. 컴파일러는 모든 개체 배열 저장소 사이트에서 내보내는 대신 Disassembly 11에 표시된 것처럼 공유 도우미 함수에 대한 호출을 내보낸다. 이 호출과 이러한 세 가지 작업은 이 경우에 필요한 추가 시간을 고려합니다.

디스어셈블리 11 Store 개체 배열 요소

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

boxing 및 unboxing

.NET 컴파일러와 CLR 간의 파트너 관계를 통해 int(System.Int32)와 같은 기본 형식을 포함한 값 형식이 참조 형식인 것처럼 참여하여 개체 참조로 처리할 수 있습니다. 이 어패런스(이 구문 설탕)를 사용하면 값 형식을 개체로 메서드에 전달하고 컬렉션에 개체로 저장할 수 있습니다.

값 형식을 "box"하려면 해당 값 형식의 복사본을 포함하는 참조 형식 개체를 만드는 것입니다. 이는 개념적으로 값 형식과 동일한 형식의 명명되지 않은 instance 필드를 사용하여 클래스를 만드는 것과 같습니다.

상자가 있는 값 형식을 "unbox"하려면 개체의 값을 값 형식의 새 instance 복사하는 것입니다.

표 9에서 알 수 있듯이(표 4와 비교하여) int를 상자로 만드는 데 필요한 분할 상환 시간 및 나중에 가비지 수집에 필요한 분할 상환 시간은 하나의 int 필드로 작은 클래스를 인스턴스화하는 데 필요한 시간과 비슷합니다.

표 9 Box 및 Unbox int Times(ns)

평균 최소값 기본 유형
29.0 21.6 box int
3.0 3.0 unbox int

boxed int 개체의 받은 편지함을 해제하려면 int에 대한 명시적 캐스트가 필요합니다. 이는 개체의 형식(메서드 테이블 주소로 표시됨)과 boxed int 메서드 테이블 주소의 비교로 컴파일됩니다. 값이 같으면 개체에서 값이 복사됩니다. 그렇지 않으면 예외가 throw됩니다. 디스어셈블리 12를 참조하세요.

디스어셈블리 12 Box 및 unbox int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

대리자

C에서 함수에 대한 포인터는 함수의 주소를 문자 그대로 저장하는 기본 데이터 형식입니다.

C++는 멤버 함수에 포인터를 추가합니다. PMF(멤버 함수)에 대한 포인터는 지연된 멤버 함수 호출을 나타냅니다. 가상 멤버가 아닌 함수의 주소는 간단한 코드 주소일 수 있지만 가상 멤버 함수의 주소는 특정 가상 멤버 함수 호출을 구현해야 합니다. 이러한 PMF의 역참조는 가상 함수 호출 입니다 .

C++ PMF를 역참조하려면 instance 제공해야 합니다.

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

몇 년 전 Visual C++ 컴파일러 개발 팀에서는 어떤 종류의 비스티가 벌거벗은 식 pa->*pmf (sans 함수 호출 연산자)인지 자문해 봤습니다. 멤버 함수에 대한 바인딩된 포인터 라고 했지만 대기 멤버 함수 호출 은 apt입니다.

관리 코드 랜드로 돌아가면 대리자 개체는 잠재 메서드 호출일 뿐입니다. 대리자 개체는 호출할 메서드와 호출할 instance 또는 정적 메서드에 대한 대리자의 경우 호출할 정적 메서드만 나타냅니다.

설명서에서 설명한 대로 대리자 선언은 특정 서명으로 메서드를 캡슐화하는 데 사용할 수 있는 참조 형식을 정의합니다. 대리자 instance 정적 또는 instance 메서드를 캡슐화합니다. 대리자는 C++의 함수 포인터와 거의 비슷합니다. 그러나 대리자는 형식이 안전하고 안전합니다.)

C#의 대리자 형식은 MulticastDelegate의 파생 형식입니다. 이 형식은 대리자를 호출할 때 호출할 (object,method) 쌍의 호출 목록을 작성하는 기능을 포함하여 풍부한 의미 체계를 제공합니다.

대리자는 비동기 메서드 호출을 위한 기능도 제공합니다. 대리자 형식을 정의하고 잠복 메서드 호출로 초기화된 대리자 형식을 인스턴스화한 후 를 통해 BeginInvoke동기적으로(메서드 호출 구문) 또는 비동기적으로 호출할 수 있습니다. 가 호출되면 BeginInvoke 런타임은 호출을 큐에 대기하고 호출자에게 즉시 반환합니다. 대상 메서드는 나중에 스레드 풀 스레드에서 호출됩니다.

이러한 풍부한 의미 체계는 모두 저렴하지 않습니다. 표 10과 표 3을 비교하면 대리자 호출은 ** 메서드 호출보다 약 8배 느립니다. 시간이 지남에 따라 개선될 것으로 예상합니다.

표 10 대리자 호출 시간(ns)

평균 최소값 기본 유형
41.1 40.9 대리자 호출

캐시 누락, 페이지 오류 및 컴퓨터 아키텍처

1983년경의 "좋은 옛날"으로 돌아가서 프로세서가 느렸고(약 500만 개의 명령/초), 상대적으로 말하면 RAM은 충분히 빠르지만 작습니다(DRAM의 256KB에서 최대 300 ns 액세스 시간) 디스크가 느리고 큽니다(10MB 디스크의 경우 최대 25ms 액세스 시간). PC 마이크로 프로세서는 스칼라 CISC였고, 대부분의 부동 소수점은 소프트웨어에 있었으며 캐시가 없었습니다.

2003년경 무어의 법칙이 20년 더 지나면 프로세서가 빠릅니다 (3GHz에서 주기당 최대 3개의 작업 실행), RAM은 비교적 느립니다(DRAM의 512MB에서 최대 100 ns 액세스 시간) 디스크는 빙하 속도가 느리고 거대 합니다(100GB 디스크에서 최대 10ms 액세스 시간). PC 마이크로 프로세서는 이제 순서가 다른 데이터 흐름 슈퍼스칼러 하이퍼스레딩 추적 캐시 RISC(디코딩된 CISC 명령 실행)이며 여러 캐시 계층이 있습니다. 예를 들어 특정 서버 지향 마이크로 프로세서에는 32KB 수준 1 데이터 캐시(대기 시간 2주기), 512KB L2 데이터 캐시 및 2MB L3 데이터 캐시(아마도 12개의 대기 시간 주기)가 있습니다. 모든 칩에.

예전에는 작성한 코드의 바이트를 계산하고 코드를 실행하는 데 필요한 주기 수를 계산할 수 있었습니다. 부하 또는 저장소는 추가와 거의 동일한 수의 주기를 사용했습니다. 최신 프로세서는 여러 함수 단위에서 분기 예측, 추측 및 순서가 벗어난(데이터 흐름) 실행을 사용하여 명령 수준 병렬 처리를 찾고 여러 전선에서 한 번에 진행합니다.

이제 가장 빠른 PC는 마이크로초당 최대 9,000개의 작업을 실행할 수 있지만 동일한 마이크로초에서는 DRAM ~10 캐시 라인에만 로드하거나 저장할 수 있습니다. 컴퓨터 아키텍처 원에서 이를 메모리 벽에 부딪치는 것으로 알려져 있습니다. 캐시는 메모리 대기 시간을 한 지점으로만 숨깁니다. 코드 또는 데이터가 캐시에 맞지 않거나 참조 지역성이 좋지 않은 경우 9000 마이크로초당 초음속 제트는 10 마이크로초 단위의 부하 세발 주기로 퇴화됩니다.

그리고 프로그램의 작업 세트 사용 가능한 실제 RAM을 초과하고 프로그램이 하드 페이지 오류를 가져오기 시작하면(디스크 액세스) 각 10,000 마이크로초 페이지 오류 서비스(디스크 액세스)에서 사용자에게 최대 9천만 개의 작업을 응답에 더 가깝게 만들 수 있는 기회를 놓치게 됩니다. 그것은 당신이 당신의 작업 세트 (vadump)를 측정하고 불필요한 할당 및 의도하지 않은 개체 그래프 보존을 제거하기 위해 CLR Profiler와 같은 도구를 사용하는 것이 오늘부터 주의 할 것이라고 믿을 정도로 끔찍합니다.

그러나 이 모든 것은 관리 코드 기본 형식의 비용을 아는 것과 어떤 관련이 있나요?모든 항목*.*

1.1GHz P-III에서 측정된 관리 코드 기본 시간의 옴니버스 목록인 표 1은 5개 수준의 명시적 생성자 호출이 있는 5개의 필드 개체를 할당, 초기화 및 회수하는 분할 상환 비용조차도 단일 DRAM 액세스보다 빠르 다는 것을 관찰합니다. 모든 수준의 온칩 캐시를 누락하는 하나의 로드만 거의 모든 단일 관리 코드 작업보다 서비스에 더 오래 걸릴 수 있습니다.

따라서 코드의 속도에 대한 열정이 있다면 알고리즘 및 데이터 구조를 설계하고 구현할 때 캐시/메모리 계층 구조를 고려하고 측정하는 것이 필수적입니다.

간단한 데모 시간: int의 배열을 더 빨리 합산하거나 동등한 연결된 int 목록을 합산하는 것이 더 빠른가요? 어느 것, 얼마나 많은, 그리고 왜?

잠시 생각해 보십시오. ints와 같은 작은 항목의 경우 배열 요소당 메모리 공간은 연결된 목록의 4분의 1입니다. (연결된 각 목록 노드에는 개체 오버헤드의 두 단어와 필드의 두 단어(다음 링크 및 int 항목)가 있습니다. 캐시 사용률이 저하될 수 있습니다. 배열 접근 방식에 대해 점수 1을 지정합니다.

그러나 배열 순회는 항목당 검사 배열 경계가 발생할 수 있습니다. 방금 검사 경계에 약간의 시간이 걸린다는 것을 보았습니다. 아마도 그 팁은 연결된 목록을 선호하는 스케일링?

디스어셈블리 13 합계 int 배열과 합계 int 연결된 목록

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

Disassembly 13을 참조하여 연결된 목록 통과를 위해 데크를 쌓아서 네 번 언롤링하고 일반적인 null 포인터 목록 끝 검사 제거했습니다. 배열 루프의 각 항목에는 6개의 명령이 필요하지만 연결된 목록 루프의 각 항목에는 11/4 = 2.75 명령만 필요합니다. 이제 더 빠르다고 생각하십니까?

테스트 조건: 먼저 100만 개의 ints 배열과 100만 개의 int(1M 목록 노드)의 단순하고 전통적인 연결된 목록을 만듭니다. 그런 다음 항목당 처음 1,000개, 10,000개, 100,000개 및 1,000,000개 항목을 추가하는 데 걸리는 시간입니다. 각 루프를 여러 번 반복하여 각 사례에 대해 가장 아첨하는 캐시 동작을 측정합니다.

더 빠른 것은 무엇인가요? 추측한 후에는 표 1의 마지막 8개 항목인 답변을 참조하세요.

결과가 흥미롭군요. 참조된 데이터가 연속 캐시 크기보다 커지면 시간이 상당히 느려집니다. 배열 버전은 두 배의 명령이 실행되더라도 연결된 목록 버전보다 항상 빠릅니다. 100,000개 항목의 경우 배열 버전이 7배 더 빠릅니다.

왜 그럴까요? 첫째, 지정된 캐시 수준에 맞는 연결된 목록 항목 수가 줄어듭니다. 이러한 모든 개체 헤더 및 링크는 낭비 공간을 연결합니다. 둘째, 최신 주문형 데이터 흐름 프로세서는 잠재적으로 미리 확대하고 배열의 여러 항목을 동시에 진행할 수 있습니다. 반면, 연결된 목록과 함께 현재 목록 노드가 캐시에 있기 전까지 프로세서는 그 후 노드에 대한 다음 링크를 가져오기 시작할 수 없습니다.

100,000개 항목의 경우 프로세서는 DRAM에서 일부 목록 노드의 캐시 라인을 읽을 때까지 엄지 손가락을 돌리는 시간의 약(평균 22-3.5)/22 = 84%를 소비합니다. 그건 나쁜 소리, 하지만 상황이 훨씬 더 악화 될 수 있습니다. 연결된 목록 항목은 작기 때문에 많은 항목이 캐시 줄에 적합합니다. 목록을 할당 순서로 트래버스하고 가비지 수집기가 힙에서 데드 개체를 압축하는 경우에도 할당 순서를 유지하므로 캐시 줄에서 노드 하나를 가져온 후 다음 여러 노드도 캐시에 있을 수 있습니다. 노드가 더 크거나 목록 노드가 임의 주소 순서에 있는 경우 방문한 모든 노드가 전체 캐시 누락일 수 있습니다. 각 목록 노드에 16바이트를 추가하면 항목당 순회 시간이 43 ns로 두 배가 됩니다. +32바이트, 67 ns/item; 64바이트를 추가하면 테스트 머신의 평균 DRAM 대기 시간이 146 ns/item으로 다시 두 배가 됩니다.

그렇다면 여기서 테이크아웃 단원은 무엇일까요? 100,000개 노드의 연결된 목록을 피하시겠습니까? 아니요. 단원은 캐시 효과가 관리 코드와 네이티브 코드의 낮은 수준의 효율성을 고려할 때 우선할 수 있다는 것입니다. 성능에 중요한 관리 코드, 특히 큰 데이터 구조를 관리하는 코드를 작성하는 경우 캐시 효과를 염두에 두고 데이터 구조 액세스 패턴을 고려하고 더 작은 데이터 공간과 적절한 참조 지역성을 위해 노력합니다.

그런데 CPU 작업 시간으로 나눈 DRAM 액세스 시간의 비율인 메모리 벽은 시간이 지남에 따라 계속 악화될 것입니다.

다음은 엄지 손가락의 몇 가지 "캐시에 민감한 디자인" 규칙입니다.

  • 두 번째 순서 효과를 예측하기 어렵고 축소판 규칙이 인쇄된 용지에 가치가 없기 때문에 시나리오를 실험하고 측정합니다.
  • 배열로 예시된 일부 데이터 구조는 암시적 인접성을 사용하여 데이터 간의 관계를 나타냅니다. 연결된 목록으로 예시된 다른 항목은 명시적 포인터(참조) 를 사용하여 관계를 나타냅니다. 암시적 인접성은 일반적으로 바람직합니다. "암시성"은 포인터에 비해 공간을 절약합니다. 및 인접성은 참조의 안정적인 지역성을 제공하며 프로세서가 다음 포인터를 추적하기 전에 더 많은 작업을 시작할 수 있습니다.
  • 일부 사용 패턴은 하이브리드 구조(작은 배열 목록, 배열 배열 또는 B-트리 목록)를 선호합니다.
  • 디스크 액세스 비용이 50,000 CPU 지침에 불과할 때 다시 설계된 디스크 액세스에 민감한 예약 알고리즘은 DRAM 액세스에 수천 개의 CPU 작업이 소요될 수 있으므로 이제 재활용해야 할 수 있습니다.
  • CLR 표시 및 압축 가비지 수집기는 개체의 상대적 순서를 유지 하므로 시간(및 동일한 스레드)에 함께 할당된 개체는 공간에 함께 유지되는 경향이 있습니다. 이 현상을 사용하여 공통 캐시 라인에 데이터를 신중하게 정렬할 수 있습니다.
  • 데이터를 자주 트래버스되고 캐시에 맞아야 하는 핫 파트와 자주 사용되지 않으며 "캐시"될 수 있는 콜드 파트로 분할할 수 있습니다.

직접 수행 시간 실험

이 문서의 타이밍 측정을 위해 Win32 고해상도 성능 카운터 QueryPerformanceCounter (및 QueryPerformanceFrequency)를 사용했습니다.

P/Invoke를 통해 쉽게 호출됩니다.

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

타이밍 루프 바로 앞과 직후에 를 호출 QueryPerformanceCounter 하고, 개수를 빼고, 1.0e9를 곱하고, 빈도로 나누고, 반복 횟수를 나눕니다. 이는 반복당 대략적인 시간(ns)입니다.

공간 및 시간 제한으로 인해 잠금, 예외 처리 또는 코드 액세스 보안 시스템을 다루지 않았습니다. 독자를 위한 연습이라고 생각해 보세요.

그런데 2003년 VS.NET 디스어셈블리 창을 사용하여 이 문서에서 디스어셈블리를 제작했습니다. 그러나 트릭이 있습니다. VS.NET 디버거에서 애플리케이션을 실행하는 경우 릴리스 모드에서 빌드된 최적화된 실행 파일로도 인라인화와 같은 최적화가 비활성화된 "디버그 모드"에서 실행됩니다. JIT 컴파일러가 내보내는 최적화된 네이티브 코드를 살펴보는 유일한 방법은 디버거 외부에서 테스트 애플리케이션을 시작하고 Debug.Processes.Attach를 사용하여 연결하는 것이었습니다.

공간 비용 모델?

아이러니하게도, 공간 고려 사항은 공간에 대한 철저한 논의를 배제합니다. 몇 가지 간단한 단락, 다음.

하위 수준 고려 사항(C#(기본 TypeAttributes.SequentialLayout) 및 x86 관련 고려 사항)

  • 값 형식의 크기는 일반적으로 필드의 총 크기이며, 4 바이트 또는 더 작은 필드가 자연 경계에 맞춰 정렬됩니다.
  • [FieldOffset(n)] 특성을 사용하여 [StructLayout(LayoutKind.Explicit)] 공용 구조체를 구현할 수 있습니다.
  • 참조 형식의 크기는 8바이트와 해당 필드의 총 크기이며, 다음 4바이트 경계로 반올림되고 4바이트 또는 더 작은 필드가 자연 경계에 맞춰집니다.
  • C#에서 열거형 선언은 임의의 정수 기본 형식(char 제외)을 지정할 수 있으므로 8비트, 16비트, 32비트 및 64비트 열거형을 정의할 수 있습니다.
  • C/C++와 마찬가지로 정수 필드의 크기를 적절하게 조정하여 더 큰 개체에서 수십 퍼센트의 공간을 면도할 수 있습니다.
  • CLR Profiler를 사용하여 할당된 참조 형식의 크기를 검사할 수 있습니다.
  • 큰 개체(수십 KB 이상)는 비용이 많이 드는 복사를 배제하기 위해 별도의 큰 개체 힙에서 관리됩니다.
  • 완료 가능한 개체는 회수할 추가 GC 생성을 수행합니다. 아끼지 않고 사용하고 Dispose 패턴을 사용하는 것이 좋습니다.

큰 그림 고려 사항:

  • 각 AppDomain에는 현재 상당한 공간 오버헤드가 발생합니다. 많은 런타임 및 프레임워크 구조는 AppDomains 간에 공유되지 않습니다.
  • 프로세스 내에서 jitted 코드는 일반적으로 AppDomains 간에 공유되지 않습니다. 런타임이 특별히 호스트되는 경우 이 동작을 재정의할 수 있습니다. 및 STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN 플래그에 대한 CorBindToRuntimeEx 설명서를 참조하세요.
  • 어떤 경우에도 jitted 코드는 프로세스 간에 공유되지 않습니다. 많은 프로세스에 로드될 구성 요소가 있는 경우 NGEN과 미리 컴파일하여 네이티브 코드를 공유하는 것이 좋습니다.

반사

"리플렉션 비용을 물어봐야 하는 경우 감당할 수 없다"고 합니다. 지금까지 읽어본 적이 있다면 어떤 비용이 드는지 물어보고 이러한 비용을 측정하는 것이 얼마나 중요한지 알 수 있습니다.

리플렉션은 유용하고 강력하지만 jitted 네이티브 코드에 비해 빠르지도 작지도 않습니다. 경고를 받았습니다. 직접 측정합니다.

결론

이제 가장 낮은 수준에서 관리 코드 비용이 무엇인지(더 많거나 적게) 알 수 있습니다. 이제 보다 스마트한 구현 절전 작업을 수행하고 더 빠른 관리 코드를 작성하는 데 필요한 기본 이해가 있습니다.

우리는 jitted 관리 코드가 네이티브 코드로 "금속에 페달"이 될 수 있음을 보았다. 과제는 현명하게 코딩하고 프레임워크의 많은 풍부하고 사용하기 쉬운 시설 중에서 현명하게 선택하는 것입니다.

성능이 중요하지 않은 설정과 제품의 가장 중요한 기능인 설정이 있습니다. 조기 최적화는 모든 악의 근원 입니다 . 그러나 효율성에 부주의한 부주의도 마찬가지입니다. 당신은 전문가, 예술가, 장인입니다. 따라서 사물의 비용을 알고 있어야 합니다. 모르는 경우나 생각하더라도 정기적으로 측정합니다.

CLR 팀의 경우 네이티브 코드보다 생산성이 훨씬 높지만 네이티브 코드보다 빠른 플랫폼을 제공하기 위해 계속 노력합니다 . 상황이 더 나아지기를 기대합니다. 기대해 주세요.

약속을 기억하십시오.

리소스