1 // Copyright Ferdinand Majerech 2014. 2 // Distributed under the Boost Software License, Version 1.0. 3 // (See accompanying file LICENSE_1_0.txt or copy at 4 // http://www.boost.org/LICENSE_1_0.txt) 5 6 /// Support for <a href="https://github.com/kiith-sa/despiker">Despiker</a> 7 module tharsis.prof.despikersender; 8 9 import std.algorithm; 10 import std.array; 11 import std.exception: assumeWontThrow, ErrnoException; 12 import std.string; 13 14 import tharsis.prof.profiler; 15 16 17 /// Exception thrown at DespikerSender errors. 18 class DespikerSenderException : Exception 19 { 20 this(string msg, string file = __FILE__, int line = __LINE__) @safe pure nothrow 21 { 22 super(msg, file, line); 23 } 24 } 25 26 // TODO: Replace writeln by logger once in Phobos 2014-10-06 27 /** Sends profiling data recorded by one or more profilers to the Desiker profiler. 28 * 29 * <a href="https://github.com/kiith-sa/despiker">Despiker</a> is a real-time graphical 30 * profiler based on Tharsis.prof. It visualizes zones in frames while the game is running 31 * and allows the user to move between frames and automatically find the worst frame. Note 32 * that at the moment, Despiker is extremely experimental and unstable. 33 * 34 * See <a href="http://defenestrate.eu/docs/despiker/index.html">Despiker 35 * documentation</a> for more info. 36 * 37 * Example: 38 * -------------------- 39 * // Profiler profiler - construct it somewhere first 40 * 41 * auto sender = new DespikerSender([profiler]); 42 * 43 * for(;;) 44 * { 45 * // Despiker will consider code in this zone (named "frame") a part of a single frame. 46 * // Which zone is considered a frame can be changed by setting the 47 * // sender.frameFilter property. 48 * auto frame = Zone(profiler, "frame"); 49 * 50 * ... frame code here, with more zones ... 51 * 52 * if(... condition to start Despiker ...) 53 * { 54 * // Looks in the current directory, directory with the binary/exe and in PATH 55 * // use sender.startDespiker("path/to/despiker") to specify explicit path 56 * sender.startDespiker(); 57 * } 58 * 59 * // No zones must be in progress while sender is being updated, so we end the frame 60 * // early by destroying it. 61 * destroy(frame); 62 * 63 * // Update the sender, send profiling data to Despiker if it is running 64 * sender.update(); 65 * } 66 * -------------------- 67 */ 68 class DespikerSender 69 { 70 /// Maximum number of profilers we can send data from. 71 enum maxProfilers = 1024; 72 private: 73 /* Profilers we're sending data from. 74 * 75 * Despiker assumes that each profiler is used to profile a separate thread 76 * (profilers_[0] - thread 0, profilers_[1] - thread 1, etc.). 77 */ 78 Profiler[] profilers_; 79 80 import std.process; 81 /* Pipes to send data to the Despiker (when running) along with pid to check its state. 82 * 83 * Reset by reset(). 84 */ 85 ProcessPipes despikerPipes_; 86 87 /* Storage for bytesSentPerProfiler. 88 * 89 * Using a fixed-size array for simplest/fastest allocation. 8kiB is acceptable but 90 * right at the edge of being acceptable... if we ever increase maxProfilers above 91 * 1024, we should use malloc. 92 */ 93 size_t[1024] bytesSentPerProfilerStorage_; 94 /* Number of bytes of profiling data sent from each profiler in profilers_. 95 * 96 * Reset by reset(). 97 */ 98 size_t[] bytesSentPerProfiler_; 99 100 // Are we sending data to a running Despiker right now? 101 bool sending_ = false; 102 103 // Used to determine which zones are frames. 104 DespikerFrameFilter frameFilter_; 105 106 public: 107 // TODO screenshot of a frame in despiker near 'matching their 'frame' zones' text 108 // 2014-10-08 109 /** Construct a DespikerSender sending data recorded by specified profilers. 110 * 111 * Despiker will show results from passed profilers side-by-side, matching their 112 * 'frame' zones. To get meaningful results, all profilers should start profiling 113 * at the same time and have one 'frame' zone for each frame of the profiled 114 * game/program. 115 * 116 * The main use of multiple profilers is to profile code in multiple threads 117 * simultaneously. If you don't need this, pass a single profiler. 118 * 119 * Params: 120 * 121 * profilers = Profilers the DespikerSender will send data from. Must not be empty. 122 * Must not have more than 1024 profilers. 123 * 124 * Note: 125 * 126 * At the moment, DespikerSender does not support resetting profilers. Neither of the 127 * passed profilers may be reset() while the DespikerSender is being used. 128 */ 129 this(Profiler[] profilers) @safe pure nothrow @nogc 130 { 131 assert(!profilers.empty, "0 profilers passed to DespikerSender constructor"); 132 assert(profilers.length <= maxProfilers, 133 "Despiker supports at most 1024 profilers at once"); 134 135 profilers_ = profilers; 136 bytesSentPerProfiler_ = bytesSentPerProfilerStorage_[0 .. profilers.length]; 137 bytesSentPerProfiler_[] = 0; 138 } 139 140 /// Is there a Despiker instance (that we are sending to) running at the moment? 141 bool sending() @safe pure nothrow const @nogc 142 { 143 return sending_; 144 } 145 146 /** Set the filter to used by Despiker to determine which zones are frames. 147 * 148 * Affects the following calls to startDespiker(), does not affect the running 149 * Despiker instance, if any. 150 */ 151 void frameFilter(DespikerFrameFilter rhs) @safe pure nothrow @nogc 152 { 153 assert(rhs.info != "NULL", 154 "DespikerFrameFilter.info must not be set to string value \"NULL\""); 155 frameFilter_ = rhs; 156 } 157 158 /** Open a Despiker window and start sending profiling data to it. 159 * 160 * Can only be called when sending is false (after DespikerSender construction 161 * or reset). 162 * 163 * By default, looks for the Despiker binary in the following order: 164 * 165 * 0: defaultPath, if specified 166 * 1: 'despiker' in current working directory 167 * 2: 'despiker' in directory of the running binary 168 * 3: 'despiker' in PATH 169 * 170 * Note: the next update() call will send all profiling data recorded so far to the 171 * Despiker. 172 * 173 * Throws: 174 * 175 * DespikerSenderException on failure (couldn't find or run Despiker in any of the 176 * searched paths, or couldn't run Despiker with path specified by defaultPath). 177 */ 178 void startDespiker(string defaultPath = null) @safe 179 { 180 assert(!sending_, 181 "Can't startDespiker() while we're already sending to a running Despiker"); 182 183 string[] errorStrings; 184 185 // Tries to run Despiker at specified path. Adds a string to errorStrings on failure. 186 bool runDespiker(string path) @trusted nothrow 187 { 188 import std.stdio: StdioException; 189 try 190 { 191 import std.conv: to; 192 // Pass the frame filter through command-line arguments. 193 auto args = [path, 194 "--frameInfo", 195 // "NULL" represents "don't care about frame info" 196 frameFilter_.info is null ? "NULL" : frameFilter_.info, 197 "--frameNestLevel", 198 to!string(frameFilter_.nestLevel)]; 199 despikerPipes_ = pipeProcess(args, Redirect.stdin); 200 sending_ = true; 201 return true; 202 } 203 catch(ProcessException e) { errorStrings ~= e.msg; } 204 catch(StdioException e) { errorStrings ~= e.msg; } 205 catch(Exception e) 206 { 207 assert(false, "Unexpected exception when trying to start Despiker"); 208 } 209 210 return false; 211 } 212 213 // Path specified by the user. 214 if(defaultPath !is null && runDespiker(defaultPath)) 215 { 216 return; 217 } 218 219 import std.file: thisExePath; 220 import std.path: dirName, buildPath; 221 // User didn't specify a path, we have to find Despiker ourselves. 222 // Try current working directory first, then the directory the game binary is 223 // in, then a despiker installation in $PATH 224 auto paths = ["./despiker", 225 thisExePath().dirName.buildPath("despiker"), 226 "despiker"]; 227 228 foreach(path; paths) if(runDespiker(path)) 229 { 230 return; 231 } 232 233 throw new DespikerSenderException 234 ("Failed to start Despiker.\n " 235 "Tried to look for it in:\n " 236 ~ defaultPath is null 237 ? "" : "0: path provided by caller: '%s'\n".format(defaultPath) ~ 238 "1: working directory, 2: directory of the running binary, 3: PATH.\n" 239 "Got errors:\n" ~ errorStrings.join("\n")); 240 } 241 242 /** Resets the sender. 243 * 244 * If Despiker is running, 'forgets' it, stops sending to it without closing it and 245 * the next startDespiker() call will launch a new Despiker instance. 246 */ 247 void reset() @trusted nothrow @nogc 248 { 249 sending_ = false; 250 // Not @nogc yet, although ProcessPipes destruction should not use GC. 251 // Probably don't need to explicitly destroy this here anyway. 252 // destroy(despikerPipes_).assumeWontThrow; 253 bytesSentPerProfiler_[] = 0; 254 } 255 256 import std.stdio; 257 /** Update the sender. 258 * 259 * Must not be called if any profilers passed to DespikerSender constructor are in a 260 * zone, or are being accessed by other threads (any synchronization must be handled 261 * by the caller). 262 */ 263 void update() @trusted nothrow 264 { 265 assert(!profilers_.canFind!(p => p.zoneNestLevel > 0), 266 "Can't update DespikerSender while one or more profilers are in a zone"); 267 268 if(!sending_) { return; } 269 // Check if Despiker got closed. 270 try 271 { 272 // tryWait may fail. There is no 'nice' way to handle that. 273 auto status = tryWait(despikerPipes_.pid); 274 275 if(status.terminated) 276 { 277 // Non-zero status means Despiker had an error. 278 if(status.status != 0) 279 { 280 writeln("Despiker exited (crashed?) with non-zero status: ", status.status) 281 .assumeWontThrow; 282 } 283 reset(); 284 return; 285 } 286 } 287 catch(ProcessException e) 288 { 289 writefln("tryWait failed: %s; assuming Despiker dead", e.msg).assumeWontThrow; 290 reset(); 291 return; 292 } 293 catch(Exception e) { assert(false, "Unexpected exception"); } 294 295 send().assumeWontThrow; 296 foreach(profiler; profilers_) { profiler.checkpointEvent(); } 297 } 298 299 private: 300 /** Send profiled data (since the last send()) to Despiker. 301 * 302 * Despite not being nothrow, should never throw. 303 */ 304 void send() @trusted 305 { 306 try foreach(uint threadIdx, profiler; profilers_) 307 { 308 const data = profiler.profileData; 309 const newData = data[bytesSentPerProfiler_[threadIdx] .. $]; 310 311 import std.array; 312 if(newData.empty) { continue; } 313 const uint bytes = cast(uint)newData.length; 314 // Send a chunk of raw profile data perfixed by a header of two 32-bit uints: 315 // thread index and chunks size in bytes. 316 uint[2] header; 317 header[0] = threadIdx; 318 header[1] = bytes; 319 despikerPipes_.stdin.rawWrite(header[]); 320 despikerPipes_.stdin.rawWrite(newData); 321 // Ensure 'somewhat real-time' sending. 322 despikerPipes_.stdin.flush(); 323 324 assert(bytesSentPerProfiler_[threadIdx] + newData.length == data.length, 325 "Newly sent data doesn't add up to all recorded data"); 326 327 bytesSentPerProfiler_[threadIdx] = data.length; 328 } 329 catch(ErrnoException e) { writeln("Failed to send data to despiker: ", e); } 330 catch(Exception e) { writeln("Unhandled exception while sending to despiker: ", e); } 331 } 332 } 333 334 /** Used to tell Despiker which zones are 'frames'. 335 * 336 * Despiker displays one frame at a time, and it lines up frames from multiple profilers 337 * enable profiling multiple threads. Properties of this struct determine which zones will 338 * be considered frames by Despiker. 339 * 340 * By default, it is enough to use a zone with info string set to "frame" (and to ensure 341 * no other zone uses the same info string). 342 */ 343 struct DespikerFrameFilter 344 { 345 /** Info string of a zone must be equal to this for that zone to be considered a frame. 346 * 347 * If null, zone info does not determine which zones are frames. Must not be set to 348 * string value "NULL". 349 */ 350 string info = "frame"; 351 352 /** Nest level of a zone must be equal to this for that zone to be considered a frame. 353 * 354 * If 0, zone nest level does not determine which zones are frames. 355 */ 356 ushort nestLevel = 0; 357 } 358