// Copyright Epic Games, Inc. All Rights Reserved. #include "CSVProfilerUtils.h" #include "Async/ParallelFor.h" #include "HAL/FileManager.h" #include "Misc/Compression.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "Misc/ScopedSlowTask.h" #include "String/LexFromString.h" #include "String/ParseTokens.h" #include "Internationalization/Internationalization.h" #include "Internationalization/Text.h" #define LOCTEXT_NAMESPACE "CsvUtils" namespace CsvUtils { namespace CsvUtils::Private { static const FText IncorrectFormatText( LOCTEXT("IncorrectFormat", "Incorrect file format - couldn't read expected magic.")); static const FText UnsupportedVersionText(LOCTEXT("UnsupportedVersion", "File is of an unsupported version.")); static const FText UnsupportedCompressionTypeText( LOCTEXT("UnsupportedCompressionType", "File uses an unsupported compression type.")); static const FText UncompressedFormatSupportText( LOCTEXT("UncompressedFormatSupport", "Uncompressed format loading is not yet supported.")); static const FText SampleDataNotFoundText( LOCTEXT("SampleDataNotFound", "Unable to find sample with name {SampleName} to serialize.")); static constexpr FStringView SampleNameKey(TEXTVIEW("SampleName")); // Should be kept up-to-date with CSVStats.CsvBinVersion enum class ECsvBinVersion : int32 { PreRelease = 1, InitialRelease, CompressionSupportAndFlags, COUNT, CURRENT = COUNT - 1 }; // Should be kept up-to-date with CSVStats.CsvBinFlags enum class ECsvBinFlags : uint32 { None = 0, HasMetadata = 0x00000001, }; ENUM_CLASS_FLAGS(ECsvBinFlags); // Should be kept up-to-date with CSVStats.CsvBinCompressionType enum class CsvBinCompressionType : uint8 { MsDeflate }; // Should be kept up-to-date with CSVStats.ECsvBinCompressionLevel enum class ECsvBinCompressionLevel : uint8 { None, Min, Max }; static bool LineIsMetadata(FStringView Line) { if (Line.TrimStartAndEnd().StartsWith(TEXT('['))) { return true; } return false; } /** * Helper function to serialize metadata from the parameter line. * @param Line Text line to read the metadata from. * @param OutMetadata The container to store the read metadata in. */ void SerializeMetadataText(const FString& Line, TMap& OutMetadata) { // Initialize state tracking variables bool bIsKey = false; FString CurrentKey; // Split the metadata line into segments at commas const TCHAR* LinePtr = *Line; FString Token; while (FParse::Token(LinePtr, Token, false, ',')) { // Check if this is a key (enclosed in square brackets) if (!bIsKey && Token.StartsWith(TEXT("[")) && Token.EndsWith(TEXT("]"))) { // Extract key without brackets and convert to lowercase CurrentKey = MoveTemp(Token); CurrentKey.MidInline(1, CurrentKey.Len() - 2); CurrentKey.ToLowerInline(); bIsKey = true; continue; } // Handle the value if (bIsKey) { // Add or append to existing value if (FString* ExistingValue = OutMetadata.Find(CurrentKey)) { *ExistingValue += TEXT(",") + Token; } else { OutMetadata.Add(MoveTemp(CurrentKey), MoveTemp(Token)); } bIsKey = false; } } } /** * Serializes binary CSV profiler data. Based on the C# code found in CsvStats.ReadCSVFromLines. * @param Lines Text lines to serialize from. * @param OutCapture Output container to store the read capture in. * @param OutErrors Optional output array to hold any reported errors. * @return True if serialization of the capture succeeded. */ bool SerializeText(TConstArrayView Lines, FCsvProfilerCapture& OutCapture, TArray* OutErrors = nullptr) { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeText); if (Lines.IsEmpty()) { return false; } // Use the first line as the header view, unless the metadata tells us to use the row at the end instead. const FString* HeaderRow = &Lines[0]; // Remove the header row from the view. Lines.RightChopInline(1); TMap Metadata; if (LineIsMetadata(Lines.Last())) { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeText::Metadata); // Serialize the metadata. SerializeMetadataText(Lines.Last(), Metadata); // Remove the metadata line. Lines.LeftChopInline(1); // New CSVs from the csv profiler have a header row at the end of the file, // since the profiler writes out the file incrementally. const FString* HasHeaderRowAtEndValue = Metadata.Find("hasheaderrowatend"); if (HasHeaderRowAtEndValue && *HasHeaderRowAtEndValue == "1") { // Swap the header row for the one at the end of the file. HeaderRow = &Lines[Lines.Num() - 1]; // Remove the end header row. Lines.LeftChopInline(1); } } // We should be left with only sample lines. const int32 NumSamples = Lines.Num(); TArray SampleNames; TArray> SampleData; int32 EventsHeadingIndex = INDEX_NONE; // Headers { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeText::Headers); TArray Headings; HeaderRow->ParseIntoArray(Headings, TEXT(","), false); SampleNames.SetNum(Headings.Num()); SampleData.SetNum(Headings.Num()); for (TArray::TIterator Iter(Headings); Iter; ++Iter) { const int32 ColumnIndex = Iter.GetIndex(); if (FStringView(*Iter).Equals(TEXTVIEW("events"), ESearchCase::IgnoreCase)) { EventsHeadingIndex = ColumnIndex; } else { SampleNames[ColumnIndex] = MoveTemp(*Iter); SampleData[ColumnIndex].SetNum(NumSamples); } } } TArray EventStrings; EventStrings.SetNum(NumSamples); // Samples { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeText::Samples); ParallelFor(NumSamples, [&](int32 Index) { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeText::Samples::Work); // Split the metadata line into segments at commas const TCHAR* LinePtr = *Lines[Index]; FString Token; int32 ColumnIndex = 0; while (FParse::Token(LinePtr, Token, false, TEXT(','))) { if (LIKELY(ColumnIndex != EventsHeadingIndex)) { LexFromString(SampleData[ColumnIndex][Index], Token); } else { EventStrings[Index] = MoveTemp(Token); } ++ColumnIndex; } }); } // Events TArray Events; if (EventsHeadingIndex != INDEX_NONE) { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeText::Events); for (TArray::TConstIterator Iter(EventStrings); Iter; ++Iter) { if (Iter->IsEmpty()) { continue; } ParseTokens( *Iter, TEXT(';'), [&](FStringView EventToken) { Events.Add({ .Name = FString(EventToken), .Frame = Iter.GetIndex() }); }, UE::String::EParseTokensOptions::IgnoreCase | UE::String::EParseTokensOptions::SkipEmpty); } // Cleanup unused stat data for events SampleNames.RemoveAt(EventsHeadingIndex, EAllowShrinking::No); SampleData.RemoveAt(EventsHeadingIndex, EAllowShrinking::No); } // Finalize { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeText::Finalize); OutCapture.Samples.Reserve(SampleNames.Num()); for (int32 SeriesIndex = 0; SeriesIndex < SampleNames.Num(); ++SeriesIndex) { FCsvProfilerSample& Sample = OutCapture.Samples.Add(SampleNames[SeriesIndex]); Sample.Name = SampleNames[SeriesIndex]; Sample.Values = MoveTemp(SampleData[SeriesIndex]); } OutCapture.Events = MoveTemp(Events); OutCapture.Metadata = MoveTemp(Metadata); } return true; } /** * Helper function to decode a 7-bit encoded integer from the parameter archive. * @param Ar The archive to read from. * @return The decoded integer. */ static uint64 Decode7bit(FArchive& Ar) { uint64 Value = 0; uint64 ByteIndex = 0; bool HasMoreBytes; do { uint8 ByteValue; Ar.Serialize(&ByteValue, 1); HasMoreBytes = ByteValue & 0x80; Value |= uint64(ByteValue & 0x7f) << (ByteIndex * 7); ++ByteIndex; } while (HasMoreBytes); return Value; } /** * Helper function to read a C# BinaryWriter-serialized string into a string builder. * @tparam CharType Character type. * @param Ar Binary archive to read the string from. * @param Builder String builder to write to. */ template void SerializeCsString(FArchive& Ar, TStringBuilderBase& Builder) { // C# BinaryWriter prefixes strings with their length as a 7-bit encoded int uint32 StringLength = Decode7bit(Ar); if constexpr (TIsCharEncodingCompatibleWith_V) { const int32 Offset = Builder.AddUninitialized(StringLength); Ar.Serialize(Builder.GetData() + Offset, StringLength); } else { TArray> Buffer; Buffer.SetNumUninitialized(StringLength); Ar.Serialize(Buffer.GetData(), StringLength); const int32 ConvertedLength = FPlatformString::ConvertedLength(Buffer.GetData(), StringLength); const int32 Offset = Builder.AddUninitialized(ConvertedLength); FPlatformString::Convert(Builder.GetData() + Offset, ConvertedLength, Buffer.GetData(), Buffer.Num()); } } /** * Helper function to read a C# BinaryWriter-serialized string into a string. * @tparam CharType Character type. * @param Ar Binary archive to read the string from. * @param OutString String to write to. */ template void SerializeCsString(FArchive& Ar, TString& OutString) { // C# BinaryWriter prefixes strings with their length as a 7-bit encoded int uint32 StringLength = Decode7bit(Ar); TArray::AllocatorType>& Data = OutString.GetCharArray(); if constexpr (TIsCharEncodingCompatibleWith_V) { Data.SetNumUninitialized(StringLength + 1); Ar.Serialize(Data.GetData(), StringLength); Data[StringLength] = CHARTEXT(CharType, '\0'); } else { TArray> Buffer; Buffer.SetNumUninitialized(StringLength); Ar.Serialize(Buffer.GetData(), Buffer.Num()); const int32 ConvertedLength = FPlatformString::ConvertedLength(Buffer.GetData(), Buffer.Num()); Data.SetNumUninitialized(ConvertedLength + 1); FPlatformString::Convert(Data.GetData(), ConvertedLength, Buffer.GetData(), Buffer.Num()); Data[ConvertedLength] = CHARTEXT(CharType, '\0'); } } /** * Helper function to read a C# BinaryWriter-serialized string into a string. * @tparam CharType Character type. * @param Ar Binary archive to read the string from. * @return The string that was read. */ template TString SerializeCsString(FArchive& Ar) { TString String; SerializeCsString(Ar, String); return String; } /** * Helper function to serialize metadata from the parameter archive. * @param Ar Binary archive to read the metadata from. * @param OutMetadata The container to store the read metadata in. */ void SerializeMetadataBin(FArchive& Ar, TMap& OutMetadata) { int32 NumValues; Ar << NumValues; OutMetadata.Reserve(NumValues); for (int32 Index = 0; Index < NumValues; Index++) { FString Key = SerializeCsString(Ar); FString Value = SerializeCsString(Ar); OutMetadata.Add(MoveTemp(Key), MoveTemp(Value)); } } /** * Serializes binary CSV profiler data. Based on the C# code found in CsvStats.ReadBinFile. * @param Ar Binary archive to read the capture data from. * @param OutCapture Output container to store the read capture in. * @param OutErrors Optional output array to hold any reported errors. * @return True if serialization of the capture succeeded. */ bool SerializeBin(FArchive& Ar, FCsvProfilerCapture& OutCapture, TArray* OutErrors = nullptr) { TRACE_CPUPROFILER_EVENT_SCOPE(Private::SerializeBin); // Check magic { TUtf8StringBuilder<256> Builder; SerializeCsString(Ar, Builder); const FUtf8StringView CsvBinMagic(UTF8TEXTVIEW("CSVBIN")); if (Builder != CsvBinMagic) { if (OutErrors) { OutErrors->Add(IncorrectFormatText); } return false; } } ECsvBinVersion Version; Ar << Version; if (Version < ECsvBinVersion::InitialRelease) { if (OutErrors) { OutErrors->Add(UnsupportedVersionText); } return false; } // Read flags ECsvBinFlags Flags = ECsvBinFlags::None; bool bCompressed = false; if (Version >= ECsvBinVersion::CompressionSupportAndFlags) { Ar << Flags; ECsvBinCompressionLevel CompressionLevel; Ar << CompressionLevel; bCompressed = CompressionLevel != ECsvBinCompressionLevel::None; if (bCompressed) { CsvBinCompressionType CompressionType; Ar << CompressionType; if (CompressionType != CsvBinCompressionType::MsDeflate) { if (OutErrors) { OutErrors->Add(UnsupportedCompressionTypeText); } return false; } } else { if (OutErrors) { OutErrors->Add(UncompressedFormatSupportText); } return false; } } else { bool bHasMetadata = false; Ar << bHasMetadata; if (bHasMetadata) { EnumAddFlags(Flags, ECsvBinFlags::HasMetadata); } } TMap Metadata; if (EnumHasAnyFlags(Flags, ECsvBinFlags::HasMetadata)) { SerializeMetadataBin(Ar, Metadata); } // Read counts int32 EventCount, ValueCount, SampleCount; Ar << EventCount << ValueCount << SampleCount; // Read sample names TMap Samples; Samples.Reserve(SampleCount); for (int32 Index = 0; Index < SampleCount; Index++) { Samples.Add(SerializeCsString(Ar)); } // Read the sample data for (int32 Index = 0; Index < SampleCount; Index++) { FString SampleName = SerializeCsString(Ar); FCsvProfilerSample* FoundSample = Samples.Find(SampleName); if (!FoundSample) { if (OutErrors) { OutErrors->Add( FText::FormatNamed(SampleDataNotFoundText, FString(SampleNameKey), FText::FromString(SampleName))); } return false; } FCsvProfilerSample& Sample = *FoundSample; Sample.Name = MoveTemp(SampleName); Ar << Sample.Average; Ar << Sample.Total; int32 StatSizeBytes; Ar << StatSizeBytes; if (bCompressed) { int32 CompressedBufferLength; Ar << CompressedBufferLength; TArray CompressedBuffer; CompressedBuffer.SetNumUninitialized(CompressedBufferLength); Ar.Serialize(CompressedBuffer.GetData(), CompressedBufferLength); int32 UncompressedBufferLength = sizeof(float) * ValueCount; TArray UncompressedValuesBuffer; UncompressedValuesBuffer.SetNumUninitialized(ValueCount); // FCompression::UncompressMemory uses the CompressionData parameter as the window size zlib uses. // Per zlib's documentation, a window size value between -8 and -15 will do a raw inflate, which // is what we want as the compressed data doesn't have any headers. constexpr int32 WindowSizeRawDeflate = -15; if (!FCompression::UncompressMemory( /* FormatName = */ NAME_Zlib, /* UncompressedBuffer = */ UncompressedValuesBuffer.GetData(), /* UncompressedSize = */ UncompressedBufferLength, /* CompressedBuffer = */ CompressedBuffer.GetData(), /* CompressedSize = */ CompressedBufferLength, /* Flags = */ COMPRESS_NoFlags, /* CompressionData = */ WindowSizeRawDeflate)) { return false; } Sample.Values = MoveTemp(UncompressedValuesBuffer); } else { unimplemented(); } } // Read the event data TArray Events; Events.Reserve(SampleCount); for (int i = 0; i < EventCount; i++) { int32 Frame; Ar << Frame; FString Name = SerializeCsString(Ar); Events.Add({.Name = MoveTemp(Name), .Frame = Frame}); } // Success - move into the output capture container. OutCapture.Samples = MoveTemp(Samples); OutCapture.Events = MoveTemp(Events); OutCapture.Metadata = MoveTemp(Metadata); return true; } } // namespace CsvUtil::Private bool ReadFromCsv(FCsvProfilerCapture& OutCapture, const TCHAR* FilePath, TArray* OutErrors /*= nullptr*/) { TRACE_CPUPROFILER_EVENT_SCOPE(ReadFromCsv); FScopedSlowTask SlowTask(1, LOCTEXT("ReadFromCsv", "Reading CSV data")); SlowTask.MakeDialog(); SlowTask.EnterProgressFrame(); OutCapture = FCsvProfilerCapture{}; TArray Lines; if (FFileHelper::LoadFileToStringArray(Lines, FilePath)) { return CsvUtils::Private::SerializeText(Lines, OutCapture, OutErrors); } return false; } bool ReadFromCsvBin(FCsvProfilerCapture& OutCapture, const TCHAR* FilePath, TArray* OutErrors /*= nullptr*/) { TRACE_CPUPROFILER_EVENT_SCOPE(ReadFromCsvBin); FScopedSlowTask SlowTask(1, LOCTEXT("ReadFromCsvBin", "Reading CSV data")); SlowTask.MakeDialog(); SlowTask.EnterProgressFrame(); OutCapture = FCsvProfilerCapture{}; if (TUniquePtr Archive(IFileManager::Get().CreateFileReader(FilePath)); Archive.IsValid()) { return CsvUtils::Private::SerializeBin(*Archive, OutCapture, OutErrors); } return false; } } #undef LOCTEXT_NAMESPACE