Bill Chiles(Roslyn 컴파일러의 프로그램 관리자)는 "Essential Performance Facts and .NET Framework Tips"라는 기사를 작성했습니다. 유명한 블로거 Hanjiang Dudiao는 이 기사를 발췌하여 몇 가지 제안과 생각을 공유했습니다. 성급하게 최적화하지 않는 것, 좋은 도구가 중요하고, 성능의 핵심은 메모리 할당 등에 있으며, 개발자는 근거 없이 맹목적으로 최적화해서는 안 되며, 먼저 성능 문제의 원인을 찾아내는 것이 중요하다고 지적했습니다.
전문은 다음과 같습니다.
이 문서에서는 C# 및 C#을 다시 작성한 경험에서 얻은 몇 가지 성능 최적화 제안을 제공합니다. VB 컴파일러를 사용하고 C# 컴파일러 작성 시 몇 가지 실제 시나리오를 예로 들어 이러한 최적화 경험을 보여줍니다. .NET 플랫폼용 애플리케이션을 개발하는 것은 매우 생산적입니다. .NET 플랫폼의 강력하고 안전한 프로그래밍 언어와 풍부한 클래스 라이브러리는 애플리케이션 개발을 효과적으로 만듭니다. 그러나 큰 힘에는 큰 책임이 따른다. .NET 프레임워크의 강력한 기능을 사용해야 하는 동시에 파일이나 데이터베이스와 같은 대량의 데이터를 처리해야 하는 경우 코드를 조정할 준비도 되어 있어야 합니다.
새 컴파일러의 성능 최적화 교훈이 애플리케이션에도 적용되는 이유
Microsoft는 관리 코드를 사용하도록 C# 및 Visual Basic 컴파일러를 다시 작성했으며 코드를 수행하는 데 사용되는 여러 가지 새로운 API를 제공합니다. 모델링 및 분석하고 컴파일 도구를 개발하여 Visual Studio에 더욱 풍부한 코드 인식 프로그래밍 환경을 제공합니다. 컴파일러를 다시 작성하고 새 컴파일러에서 Visual Studio를 개발한 경험을 통해 매우 유용한 성능 최적화 경험을 얻을 수 있었으며 이는 대규모 .NET 애플리케이션이나 대용량 데이터를 처리해야 하는 일부 APP에도 사용할 수 있습니다. C# 컴파일러 예제에서 이러한 통찰력을 얻기 위해 컴파일러에 대해 아무것도 알 필요가 없습니다.
Visual Studio는 컴파일러 API를 사용하여 코드 키워드 색상 지정, 구문 채우기 목록, 오류 물결선 프롬프트, 매개 변수 프롬프트, 코드 문제 및 수정 제안 등과 같은 강력한 Intellisense 기능을 구현합니다. 이러한 기능은 매우 인기가 있습니다. 개발자들 사이에서. 개발자가 코드를 입력하거나 수정하면 Visual Studio는 코드를 동적으로 컴파일하여 코드 분석 및 프롬프트를 얻습니다.
사용자는 앱과 상호작용할 때 일반적으로 소프트웨어가 반응하기를 원합니다. 명령을 입력하거나 실행하는 동안 애플리케이션 인터페이스가 차단되어서는 안 됩니다. 사용자가 계속 입력하는 동안 도움말이나 프롬프트가 빠르게 표시되거나 중지될 수 있습니다. 오늘날의 앱은 장기간 계산을 수행할 때 UI 스레드를 차단하여 사용자가 프로그램이 충분히 원활하지 않다고 느끼게 하는 일을 피해야 합니다.
새로운 컴파일러에 대해 더 자세히 알고 싶다면 .NET Compiler Platform("Roslyn")을 방문하세요.
Basic Essentials
.NET에서 성능 튜닝을 수행할 때 응답성이 좋은 애플리케이션을 최적화하고 개발할 때 다음 기본 팁을 고려하십시오.
팁 1: 너무 일찍 최적화하지 마세요
코드 작성은 생각보다 복잡합니다. 성능을 위해 디버깅 및 최적화되었습니다. 숙련된 프로그래머는 일반적으로 자연스럽게 문제에 대한 해결책을 찾아내고 효율적인 코드를 작성합니다. 그러나 때로는 코드를 조기에 최적화하는 문제에 빠질 수도 있습니다. 예를 들어, 간단한 배열로도 충분하지만 해시 테이블을 사용하려면 최적화해야 하고, 때로는 간단한 재계산으로도 충분하지만 메모리 누수가 발생할 수 있는 복잡한 캐시를 사용해야 하는 경우도 있습니다. 문제가 발견되면 먼저 성능 문제를 테스트한 다음 코드를 분석해야 합니다.
팁 2: 평가는 하지 않고 추측만 합니다
분석과 측정은 거짓말을 하지 않습니다. 측정을 통해 CPU가 최대 용량으로 작동하는지 또는 디스크 I/O 차단이 있는지 확인할 수 있습니다. 프로필은 애플리케이션이 할당하는 메모리의 양과 메모리 양, CPU가 가비지 수집에 많은 시간을 소비하는지 여부를 알려줍니다.
주요 사용자 경험이나 시나리오에 대한 성능 목표를 설정하고 성능을 측정하기 위한 테스트를 작성해야 합니다. 표준 이하 성능의 이유를 분석하기 위해 과학적 방법을 사용하는 단계는 다음과 같습니다. 평가 보고서를 지침으로 사용하고, 가능한 상황을 가정하고, 실험 코드를 작성하거나 코드를 수정하여 가정이나 수정 사항을 검증합니다. 기본 성능 지표를 설정하고 자주 테스트하면 성능 저하를 일으키는 일부 변경을 피할 수 있어 불필요한 변경에 시간을 낭비하는 일을 막을 수 있습니다.
팁 3: 좋은 도구가 중요합니다
좋은 도구를 사용하면 성능에 영향을 미치는 가장 큰 요소(CPU, 메모리, 디스크)를 빠르게 찾고 이러한 병목 현상을 찾는 데 도움이 됩니다. Microsoft는 Visual Studio 프로파일러, Windows Phone 분석 도구, PerfView 등 다양한 성능 테스트 도구를 출시했습니다.
PerfView는 주로 성능에 영향을 미치는 일부 심층적인 문제(디스크 I)에 초점을 맞춘 강력한 무료 도구입니다. /O, GC 이벤트, 메모리), 이에 대한 예는 나중에 표시됩니다. 성능 관련 Windows용 이벤트 추적(ETW) 이벤트를 캡처하고 애플리케이션, 프로세스, 스택 및 스레드 규모에서 이 정보를 볼 수 있습니다. PerfView는 애플리케이션에서 할당된 메모리 양과 메모리 할당에 대한 애플리케이션의 함수 및 호출 스택 기여도를 표시할 수 있습니다. 이러한 측면에 대한 자세한 내용을 보려면 도구 다운로드와 함께 출시된 PerfView에 대한 매우 자세한 도움말, 데모 및 비디오 자습서(예: Channel9의 비디오 자습서)를 볼 수 있습니다.
팁 4: 모든 것은 메모리 할당과 관련되어 있습니다
반응형 .NET 기반 애플리케이션을 작성하려면 버블 정렬 대신 퀵 정렬을 사용하는 등 좋은 알고리즘을 사용하는 것이 중요하다고 생각할 수도 있지만 그렇지 않습니다. 반응형 앱을 작성할 때 가장 큰 요소는 메모리 할당입니다. 특히 앱이 매우 크거나 많은 양의 데이터를 처리하는 경우 더욱 그렇습니다.
새로운 컴파일러 API를 사용하여 반응형 IDE를 개발하는 과정에서 대부분의 작업은 메모리 할당을 방지하고 캐시 전략을 관리하는 방법에 소요됩니다. PerfView 추적은 새로운 C# 및 VB 컴파일러의 성능이 기본적으로 CPU 성능 병목 현상과 무관하다는 것을 보여줍니다. 컴파일러는 수백, 수천 또는 수만 줄의 코드를 읽고, 메타데이터를 읽고, 컴파일된 코드를 생성합니다. 이러한 작업은 실제로 I/O 바인딩 집약적입니다. UI 스레드 대기 시간은 거의 전적으로 가비지 수집으로 인해 발생합니다. .NET 프레임워크는 가비지 수집 성능을 고도로 최적화했으며 애플리케이션 코드가 실행될 때 대부분의 가비지 수집 작업을 병렬로 수행할 수 있습니다. 그러나 단일 메모리 할당 작업으로 인해 비용이 많이 드는 가비지 수집 작업이 발생할 수 있으므로 GC는 가비지 수집(예: 2세대 유형 가비지 수집)을 위해 모든 스레드를 일시적으로 중단합니다.
일반적인 메모리 할당 및 예시
이 부분의 예제 뒤에는 메모리 할당에 대한 내용이 거의 없습니다. 그러나 대규모 응용 프로그램이 메모리 할당을 초래하는 이러한 작은 표현식을 충분히 실행하는 경우 이러한 표현식으로 인해 수백 메가바이트 또는 심지어 기가바이트의 메모리 할당이 발생할 수 있습니다. 예를 들어, 성능 테스트 팀이 입력 시나리오에서 문제를 찾기 전에 개발자가 컴파일러에서 코드를 작성하는 것을 시뮬레이션하는 1분 테스트에서는 수 기가바이트의 메모리를 할당합니다.
Boxing
Boxing은 일반적으로 스레드 스택이나 데이터 구조에 할당되는 값 유형이나 임시 값을 객체에 래핑해야 할 때 발생합니다(예: 객체 할당) 데이터를 저장하고 Object 객체에 대한 포인터를 반환합니다. .NET Framework에서는 메서드의 시그니처나 형식의 할당 위치로 인해 값 형식을 자동으로 상자에 넣기도 합니다. 값 유형을 참조 유형으로 래핑하면 메모리 할당이 발생합니다. .NET 프레임워크와 언어는 불필요한 박싱을 방지하려고 노력하지만 때때로 우리가 인지하지 못하는 사이에 박싱 작업이 발생합니다. 과도한 박싱 작업은 애플리케이션에서 M on G 메모리를 할당하므로 가비지 수집이 더 자주 발생하고 시간이 더 오래 걸립니다.
PerfView에서 박싱 작업을 보려면 추적(trace)을 활성화한 다음 애플리케이션 이름 아래에서 GC Heap Alloc 항목을 확인하세요(PerfView는 모든 프로세스의 리소스 할당을 보고한다는 점을 기억하세요). System.Int32 및 System.Char와 같은 일부 값 유형이 표시되면 박싱이 발생했습니다. 유형을 선택하면 호출 스택과 박싱 작업이 발생한 함수가 표시됩니다.
예제 1 문자열 메서드 및 해당 값 유형 매개 변수
다음 예제 코드는 대규모 시스템에서 잠재적으로 불필요한 박싱과 빈번한 박싱 작업을 보여줍니다.
public class Logger { public static void WriteLine(string s) { /*...*/ } } public class BoxingExample { public void Log(int id, int size) { var s = string.Format("{0}:{1}", id, size); Logger.WriteLine(s); } }
이것은 기본 로그 클래스이므로 앱은 로그를 기록하기 위해 Log 함수를 매우 자주 호출합니다. 문제는 string.Format 메서드를 호출하면 문자열 유형과 두 개의 객체 유형(
String.Format Method (String, Object, Object)
)을 허용하는 오버로드된 메서드가 호출된다는 것입니다. 오버로드된 메서드를 사용하려면 .NET Framework에서 int 형식을 개체 형식으로 묶고 이를 메서드 호출에 전달해야 합니다. 이 문제를 해결하기 위해 메서드는 id.ToString() 및 size.ToString() 메서드를 호출한 다음 이를 string.Format 메서드에 전달하는 것입니다. ToString() 메서드를 호출하면 실제로 할당이 발생합니다. 문자열이지만 문자열에서는 Format 메서드 내부에서 무슨 일이 일어나든 문자열 유형 할당이 발생합니다.
이 기본 호출 string.Format은 단지 문자열 연결이라고 생각할 수 있으므로 다음과 같은 코드를 작성할 수 있습니다.
var s = id.ToString() + ':' + size.ToString();
실제로 위 코드 줄은 컴파일 중에 위 명령문이 호출되기 때문에 박싱을 발생시킵니다.
string.Concat(Object, Object, Object);
이 방법의 경우, .NET Framework는 Concat 메서드를 호출하기 위해 문자 상수를 상자에 넣어야 합니다.
해결책:
위의 작은따옴표를 큰따옴표로 바꾸는 것은 매우 간단합니다. 즉, 박싱을 방지하려면 문자 상수를 문자열 상수로 바꾸십시오. 왜냐하면 문자열 유형이 이미 참조 유형이기 때문입니다.
var s = id.ToString() + ":" + size.ToString();
예제 2 열거형의 Boxing
다음 예제는 새로운 C#의 결과입니다. VB 컴파일러는 특히 사전에서 검색 작업을 수행할 때 열거형 유형을 자주 사용하기 때문에 많은 양의 메모리를 할당합니다.
public enum Color { Red, Green, Blue } public class BoxingExample { private string name; private Color color; public override int GetHashCode() { return name.GetHashCode() ^ color.GetHashCode(); } }
문제는 매우 숨겨져 있습니다. PerfView는 enmu.GetHashCode()에 내부 구현 이유로 인해 박싱 작업이 있음을 알려줍니다. Box, PerfView를 자세히 살펴보면 GetHashCode를 호출할 때마다 두 개의 박싱 작업이 생성되는 것을 볼 수 있습니다. 컴파일러는 이를 한 번 삽입하고 .NET Framework는 이를 한 번 더 삽입합니다.
해결 방법:
GetHashCode를 호출할 때 열거형의 기본 표현을 캐스팅하면 이 박싱 작업을 피할 수 있습니다.
((int)color).GetHashCode()
열거형을 사용할 때 자주 발생하는 또 다른 boxing 작업은 enum.HasFlag입니다. HasFlag에 전달되는 매개변수는 boxing되어야 합니다. 대부분의 경우 HasFlag를 반복적으로 호출하여 비트 작업을 통해 테스트하는 것은 매우 간단하며 메모리 할당이 필요하지 않습니다.
첫 번째 기본 사항을 기억하고 성급하게 최적화하지 마세요. 그리고 모든 코드를 조기에 다시 작성하지 마십시오. 이러한 박싱의 비용에 주의를 기울이고 도구를 통해 주요 문제를 찾아낸 후에만 코드를 수정해야 합니다.
문자열
문자열 작업은 메모리 할당의 가장 큰 원인 중 하나이며 일반적으로 PerfView에서 메모리 할당의 상위 5가지 원인 중 하나입니다. 애플리케이션은 직렬화를 위해 JSON 및 REST를 나타내는 문자열을 사용합니다. 문자열은 열거형을 지원하지 않고도 다른 시스템과 상호작용하는 데 사용될 수 있습니다. 문자열 작업이 성능에 심각한 영향을 미친다는 것을 알게 되면 Format(), Concat(), Split(), Join(), Substring() 및 문자열 클래스의 기타 메서드에 주의를 기울여야 합니다. StringBuilder를 사용하면 여러 문자열을 연결할 때 여러 개의 새 문자열을 생성하는 오버헤드를 피할 수 있지만 가능한 성능 병목 현상을 방지하려면 StringBuilder 생성도 잘 제어해야 합니다.
例3 字符串操作
在C#编译器中有如下方法来输出方法前面的xml格式的注释。
public void WriteFormattedDocComment(string text) { string[] lines = text.Split(new[] {"\r\n", "\r", "\n"}, StringSplitOptions.None); int numLines = lines.Length; bool skipSpace = true; if (lines[0].TrimStart().StartsWith("///")) { for (int i = 0; i < numLines; i++) { string trimmed = lines[i].TrimStart(); if (trimmed.Length < 4 || !char.IsWhiteSpace(trimmed[3])) { skipSpace = false; break; } } int substringStart = skipSpace ? 4 : 3; for (int i = 0; i < numLines; i++) Console.WriteLine(lines[i].TrimStart().Substring(substringStart)); } else { /* ... */ } }
可以看到,在这片代码中包含有很多字符串操作。代码中使用类库方法来将行分割为字符串,来去除空格,来检查参数text是否是XML文档格式的注释,然后从行中取出字符串处理。
在WriteFormattedDocComment方法每次被调用时,第一行代码调用Split()就会分配三个元素的字符串数组。编译器也需要产生代码来分配这个数组。因为编译器并不知道,如果Splite()存储了这一数组,那么其他部分的代码有可能会改变这个数组,这样就会影响到后面对WriteFormattedDocComment方法的调用。每次调用Splite()方法也会为参数text分配一个string,然后在分配其他内存来执行splite操作。
WriteFormattedDocComment方法中调用了三次TrimStart()方法,在内存环中调用了两次,这些都是重复的工作和内存分配。更糟糕的是,TrimStart()的无参重载方法的签名如下:
namespace System { public class String { public string TrimStart(params char[] trimChars); } }
该方法签名意味着,每次对TrimStart()的调用都回分配一个空的数组以及返回一个string类型的结果。
最后,调用了一次Substring()方法,这个方法通常会导致在内存中分配新的字符串。
解决方法:
和前面的只需要小小的修改即可解决内存分配的问题不同。在这个例子中,我们需要从头看,查看问题然后采用不同的方法解决。比如,可以意识到WriteFormattedDocComment()方法的参数是一个字符串,它包含了方法中需要的所有信息,因此,代码只需要做更多的index操作,而不是分配那么多小的string片段。
下面的方法并没有完全解,但是可以看到如何使用类似的技巧来解决本例中存在的问题。C#编译器使用如下的方式来消除所有的额外内存分配。
private int IndexOfFirstNonWhiteSpaceChar(string text, int start) { while (start < text.Length && char.IsWhiteSpace(text[start])) start++; return start; } private bool TrimmedStringStartsWith(string text, int start, string prefix) { start = IndexOfFirstNonWhiteSpaceChar(text, start); int len = text.Length - start; if (len < prefix.Length) return false; for (int i = 0; i < len; i++) { if (prefix[i] != text[start + i]) return false; } return true; }
WriteFormattedDocComment() 方法的第一个版本分配了一个数组,几个子字符串,一个trim后的子字符串,以及一个空的params数组。也检查了”///”。修改后的代码仅使用了index操作,没有任何额外的内存分配。它查找第一个非空格的字符串,然后逐个字符串比较来查看是否以”///”开头。和使用TrimStart()不同,修改后的代码使用IndexOfFirstNonWhiteSpaceChar方法来返回第一个非空格的开始位置,通过使用这种方法,可以移除WriteFormattedDocComment()方法中的所有额外内存分配。
例4 StringBuilder
本例中使用StringBuilder。下面的函数用来产生泛型类型的全名:
public class Example { // Constructs a name like "SomeType<T1, T2, T3>" public string GenerateFullTypeName(string name, int arity) { StringBuilder sb = new StringBuilder(); sb.Append(name); if (arity != 0) { sb.Append("<"); for (int i = 1; i < arity; i++) { sb.Append("T"); sb.Append(i.ToString()); sb.Append(", "); } sb.Append("T"); sb.Append(i.ToString()); sb.Append(">"); } return sb.ToString(); } }
注意力集中到StringBuilder实例的创建上来。代码中调用sb.ToString()会导致一次内存分配。在StringBuilder中的内部实现也会导致内部内存分配,但是我们如果想要获取到string类型的结果化,这些分配无法避免。
解决方法:
要解决StringBuilder对象的分配就使用缓存。即使缓存一个可能被随时丢弃的单个实例对象也能够显著的提高程序性能。下面是该函数的新的实现。除了下面两行代码,其他代码均相同
// Constructs a name like "Foo<T1, T2, T3>" public string GenerateFullTypeName(string name, int arity) { StringBuilder sb = AcquireBuilder(); /* Use sb as before */ return GetStringAndReleaseBuilder(sb); }
关键部分在于新的 AcquireBuilder()和GetStringAndReleaseBuilder()方法:
[ThreadStatic] private static StringBuilder cachedStringBuilder; private static StringBuilder AcquireBuilder() { StringBuilder result = cachedStringBuilder; if (result == null) { return new StringBuilder(); } result.Clear(); cachedStringBuilder = null; return result; } private static string GetStringAndReleaseBuilder(StringBuilder sb) { string result = sb.ToString(); cachedStringBuilder = sb; return result; }
上面方法实现中使用了 thread-static字段来缓存StringBuilder对象,这是由于新的编译器使用了多线程的原因。很可能会忘掉这个ThreadStatic声明。Thread-static字符为每个执行这部分的代码的线程保留一个唯一的实例。
如果已经有了一个实例,那么AcquireBuilder()方法直接返回该缓存的实例,在清空后,将该字段或者缓存设置为null。否则AcquireBuilder()创建一个新的实例并返回,然后将字段和cache设置为null 。
StringBuilder 처리가 끝나면 GetStringAndReleaseBuilder() 메서드를 호출하여 문자열 결과를 가져옵니다. 그런 다음 StringBuilder를 필드에 저장하거나 캐시하고 결과를 반환합니다. 이 코드를 반복적으로 실행하여 여러 StringBuilder 개체를 만드는 것이 가능하지만, 그런 경우는 거의 없습니다. 나중에 사용하기 위해 마지막으로 해제된 StringBuilder 개체만 코드에 저장됩니다. 새 컴파일러에서는 이 간단한 캐싱 전략을 통해 불필요한 메모리 할당이 크게 줄어듭니다. .NET Framework 및 MSBuild의 일부 모듈도 유사한 기술을 사용하여 성능을 향상시킵니다.
간단한 캐싱 전략은 크기 제한이 있기 때문에 좋은 캐싱 설계를 따라야 합니다. 캐시를 사용하려면 이전보다 더 많은 코드가 필요할 수 있으며 더 많은 유지 관리가 필요합니다. 이것이 문제라는 것을 발견한 후에만 캐싱 전략을 채택해야 합니다. PerfView는 StringBuilder가 메모리 할당에 크게 기여한다는 것을 보여주었습니다.