Files
InstanceBrick--nightcap/tests/chrome_trace_writer_test.cc
T
vii 35359f3575 perf(etc2): carve upload staging out of shared blocks
Each ETC2 upload created and destroyed its own staging buffer on the render
thread. Uploads now take ranges of 16 MiB blocks, reused once every range
is released; four empty blocks are kept. Profile traces gain per-frame CPU
time counters and slices for staging and driver memory calls.
2026-09-22 21:31:58 -05:00

381 lines
12 KiB
C++

#include "mocktail/graphics/chrome_trace_writer.h"
#include <gtest/gtest.h>
#include <signal.h>
#include <sys/resource.h>
#include <unistd.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include <set>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace mocktail {
namespace graphics {
namespace {
class TemporaryDirectory final {
public:
TemporaryDirectory() {
char pattern[] = "/tmp/mocktail_chrome_trace_XXXXXX";
const char* created = mkdtemp(pattern);
if (created != nullptr) root_ = created;
}
~TemporaryDirectory() {
std::error_code error;
std::filesystem::remove_all(root_, error);
}
const std::filesystem::path& root() const { return root_; }
private:
std::filesystem::path root_;
};
std::string ReadFile(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
std::ostringstream content;
content << input.rdbuf();
return content.str();
}
nlohmann::json ParseTrace(const std::filesystem::path& path) {
return nlohmann::json::parse(ReadFile(path));
}
std::unique_ptr<ChromeTraceWriter> OpenOrFail(
const std::filesystem::path& path) {
std::string error;
auto writer = ChromeTraceWriter::Open(path, &error);
EXPECT_NE(writer, nullptr) << error;
return writer;
}
TEST(ChromeTraceWriterTest, WritesSliceAsCompleteEventInMicroseconds) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
writer->Slice("vkQueuePresentKHR", "present", 5'000'123'456ULL,
5'002'623'456ULL, {{"frame", 7}, {"result", -4}});
writer->Close();
const nlohmann::json trace = ParseTrace(path);
ASSERT_TRUE(trace.is_array());
ASSERT_EQ(trace.size(), 1u);
const nlohmann::json& event = trace[0];
EXPECT_EQ(event["name"], "vkQueuePresentKHR");
EXPECT_EQ(event["cat"], "present");
EXPECT_EQ(event["ph"], "X");
EXPECT_EQ(event["ts"], 5'000'123);
EXPECT_EQ(event["dur"], 2'500);
EXPECT_EQ(event["pid"], static_cast<int>(getpid()));
EXPECT_EQ(event["tid"], static_cast<int>(gettid()));
EXPECT_EQ(event["args"]["frame"], 7);
EXPECT_EQ(event["args"]["result"], -4);
}
TEST(ChromeTraceWriterTest, WritesCounterSample) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
writer->Counter("frame interval (ms)", 9'000'000ULL, 16.5);
writer->Close();
const nlohmann::json trace = ParseTrace(path);
ASSERT_EQ(trace.size(), 1u);
EXPECT_EQ(trace[0]["name"], "frame interval (ms)");
EXPECT_EQ(trace[0]["ph"], "C");
EXPECT_EQ(trace[0]["ts"], 9'000);
EXPECT_DOUBLE_EQ(trace[0]["args"]["value"].get<double>(), 16.5);
}
TEST(ChromeTraceWriterTest, EmptyTraceIsAnEmptyArray) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
writer->Close();
EXPECT_EQ(ParseTrace(path), nlohmann::json::array());
}
TEST(ChromeTraceWriterTest, EscapesNames) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
writer->Slice("say \"hi\"\\now", "c\ta", 0, 1000);
writer->Close();
const nlohmann::json trace = ParseTrace(path);
ASSERT_EQ(trace.size(), 1u);
EXPECT_EQ(trace[0]["name"], "say \"hi\"\\now");
EXPECT_EQ(trace[0]["cat"], "c\ta");
}
TEST(ChromeTraceWriterTest, FileIsValidJsonBetweenWrites) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
// A session killed at any of these points leaves a loadable trace.
EXPECT_EQ(ParseTrace(path), nlohmann::json::array());
writer->Slice("first", "test", 1000, 2000);
writer->Flush();
EXPECT_EQ(ParseTrace(path).size(), 1u);
writer->Slice("second", "test", 3000, 4000);
writer->Flush();
const nlohmann::json trace = ParseTrace(path);
ASSERT_EQ(trace.size(), 2u);
EXPECT_EQ(trace[0]["name"], "first");
EXPECT_EQ(trace[1]["name"], "second");
writer->Close();
EXPECT_EQ(ParseTrace(path), trace);
}
TEST(ChromeTraceWriterTest, DropsEventsAfterClose) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
writer->Slice("kept", "test", 0, 1);
writer->Close();
writer->Slice("dropped", "test", 2, 3);
writer->Counter("dropped", 4, 1.0);
writer->Flush();
writer->Close();
const nlohmann::json trace = ParseTrace(path);
ASSERT_EQ(trace.size(), 1u);
EXPECT_EQ(trace[0]["name"], "kept");
}
TEST(ChromeTraceWriterTest, KeepsEveryEventFromConcurrentThreads) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
constexpr int kThreads = 8;
constexpr int kEventsPerThread = 20'000;
std::vector<std::thread> threads;
for (int thread = 0; thread < kThreads; ++thread) {
threads.emplace_back([&writer, thread] {
for (int event = 0; event < kEventsPerThread; ++event) {
writer->Slice("work", "test", 1000, 2000,
{{"thread", thread}, {"event", event}});
}
});
}
for (std::thread& thread : threads) {
thread.join();
}
writer->Close();
const nlohmann::json trace = ParseTrace(path);
ASSERT_EQ(trace.size(), static_cast<std::size_t>(kThreads) *
kEventsPerThread);
std::set<std::pair<int, int>> seen;
std::set<int> tids;
for (const nlohmann::json& event : trace) {
seen.emplace(event["args"]["thread"].get<int>(),
event["args"]["event"].get<int>());
tids.insert(event["tid"].get<int>());
}
EXPECT_EQ(seen.size(), trace.size());
EXPECT_EQ(tids.size(), static_cast<std::size_t>(kThreads));
}
TEST(ChromeTraceWriterTest, DestructorClosesTheArray) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
{
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
writer->Slice("only", "test", 0, 1000);
}
EXPECT_EQ(ParseTrace(path).size(), 1u);
}
TEST(ChromeTraceWriterTest, OpenReportsUnwritablePath) {
TemporaryDirectory directory;
std::string error;
const auto writer = ChromeTraceWriter::Open(
directory.root() / "missing" / "trace.json", &error);
EXPECT_EQ(writer, nullptr);
EXPECT_NE(error.find("missing"), std::string::npos) << error;
}
TEST(ChromeTraceWriterTest, OpenCreatesOrTruncatesTheFile) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
std::ofstream(path) << "stale content that is not json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
writer->Close();
EXPECT_EQ(ParseTrace(path), nlohmann::json::array());
}
TEST(ChromeTraceWriterTest, ScopeRecordsItsLifetimeAndArgs) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
const std::uint64_t before = TraceClockNanos();
{
TraceScope scope(writer.get(), "scoped", "test");
scope.Arg("count", 3);
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
const std::uint64_t after = TraceClockNanos();
writer->Close();
const nlohmann::json trace = ParseTrace(path);
ASSERT_EQ(trace.size(), 1u);
const auto ts = trace[0]["ts"].get<std::uint64_t>();
const auto dur = trace[0]["dur"].get<std::uint64_t>();
EXPECT_GE(ts, before / 1000);
EXPECT_GE(dur, 2'000u);
EXPECT_LE(ts + dur, after / 1000 + 1);
EXPECT_EQ(trace[0]["args"]["count"], 3);
}
TEST(ChromeTraceWriterTest, ScopeWithoutWriterRecordsNothing) {
TraceScope scope(nullptr, "unused", "test");
scope.Arg("ignored", 1);
}
std::size_t ThreadCount() {
return static_cast<std::size_t>(
std::distance(std::filesystem::directory_iterator("/proc/self/task"),
std::filesystem::directory_iterator()));
}
// A failed write closes the trace, so the writer thread has nothing left to
// do and exits instead of ticking for the rest of the process.
TEST(ChromeTraceWriterTest, WriterThreadExitsAfterAWriteFailure) {
TemporaryDirectory directory;
const auto path = directory.root() / "trace.json";
const std::size_t idle_threads = ThreadCount();
auto writer = OpenOrFail(path);
ASSERT_NE(writer, nullptr);
ASSERT_EQ(ThreadCount(), idle_threads + 1);
// With SIGXFSZ ignored, a write past RLIMIT_FSIZE fails with EFBIG.
struct sigaction ignore {};
ignore.sa_handler = SIG_IGN;
struct sigaction saved_action {};
ASSERT_EQ(sigaction(SIGXFSZ, &ignore, &saved_action), 0);
rlimit saved_limit{};
ASSERT_EQ(getrlimit(RLIMIT_FSIZE, &saved_limit), 0);
rlimit no_growth = saved_limit;
no_growth.rlim_cur = 0;
ASSERT_EQ(setrlimit(RLIMIT_FSIZE, &no_growth), 0);
writer->Slice("lost", "test", 0, 1);
writer->Flush();
EXPECT_EQ(setrlimit(RLIMIT_FSIZE, &saved_limit), 0);
EXPECT_EQ(sigaction(SIGXFSZ, &saved_action, nullptr), 0);
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (ThreadCount() != idle_threads &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
EXPECT_EQ(ThreadCount(), idle_threads);
writer->Close();
}
TEST(ChromeTraceWriterTest, ActiveProfileTraceIsOffWithoutEnvironment) {
ASSERT_EQ(std::getenv(kProfileTraceEnvironment), nullptr);
EXPECT_EQ(ActiveProfileTrace(), nullptr);
}
TEST(ChromeTraceWriterTest, CpuClocksAdvanceWithWork) {
// The process window encloses the thread window.
const std::uint64_t process_before = TraceProcessCpuNanos();
const std::uint64_t thread_before = TraceThreadCpuNanos();
volatile std::uint64_t sink = 0;
const auto until =
std::chrono::steady_clock::now() + std::chrono::milliseconds(20);
while (std::chrono::steady_clock::now() < until) {
sink = sink + 1;
}
const std::uint64_t thread_used = TraceThreadCpuNanos() - thread_before;
const std::uint64_t process_used = TraceProcessCpuNanos() - process_before;
EXPECT_GE(thread_used, 10'000'000U);
EXPECT_GE(process_used, thread_used);
}
TEST(FrameCpuSamplerTest, FirstPresentHasNoFrame) {
FrameCpuSampler sampler;
FrameCpuSample sample;
EXPECT_FALSE(sampler.Sample(1, 10'000'000, 50'000'000, &sample));
}
TEST(FrameCpuSamplerTest, MeasuresCpuSpentSinceThePreviousPresent) {
FrameCpuSampler sampler;
FrameCpuSample sample;
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
ASSERT_TRUE(sampler.Sample(1, 18'000'000, 90'000'000, &sample));
EXPECT_TRUE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.thread_ms, 8.0);
EXPECT_DOUBLE_EQ(sample.process_ms, 40.0);
}
TEST(FrameCpuSamplerTest, SkipsThreadCpuWhenAnotherThreadPresentedLast) {
FrameCpuSampler sampler;
FrameCpuSample sample;
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
ASSERT_TRUE(sampler.Sample(2, 3'000'000, 70'000'000, &sample));
EXPECT_FALSE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.process_ms, 20.0);
// Thread 2 presented last, so its next frame measures from here.
ASSERT_TRUE(sampler.Sample(2, 7'000'000, 80'000'000, &sample));
EXPECT_TRUE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.thread_ms, 4.0);
}
TEST(FrameCpuSamplerTest, ClocksReadOutOfOrderDoNotWrap) {
// Two presenting threads may read the process clock in one order and take
// the lock in the other; a reused thread id restarts its thread clock.
FrameCpuSampler sampler;
FrameCpuSample sample;
sampler.Sample(1, 10'000'000, 50'000'000, &sample);
ASSERT_TRUE(sampler.Sample(1, 4'000'000, 45'000'000, &sample));
EXPECT_DOUBLE_EQ(sample.process_ms, 0.0);
EXPECT_FALSE(sample.has_thread);
// The later process reading stays the baseline.
ASSERT_TRUE(sampler.Sample(1, 6'000'000, 60'000'000, &sample));
EXPECT_DOUBLE_EQ(sample.process_ms, 10.0);
EXPECT_TRUE(sample.has_thread);
EXPECT_DOUBLE_EQ(sample.thread_ms, 2.0);
}
} // namespace
} // namespace graphics
} // namespace mocktail