1 /*
2 * Copyright 2024 Google LLC
3 *
4 * Use of this source code is governed by a BSD-style license that can be
5 * found in the LICENSE file.
6 */
7
8 #include "tests/Test.h"
9
10 #if defined(SK_GRAPHITE)
11
12 #include "include/core/SkCanvas.h"
13 #include "include/core/SkPaint.h"
14 #include "include/core/SkTextBlob.h"
15 #include "include/effects/SkGradientShader.h"
16 #include "include/gpu/graphite/PrecompileContext.h"
17 #include "include/gpu/graphite/Surface.h"
18 #include "include/gpu/graphite/precompile/PaintOptions.h"
19 #include "include/gpu/graphite/precompile/Precompile.h"
20 #include "include/gpu/graphite/precompile/PrecompileShader.h"
21 #include "src/gpu/graphite/ContextPriv.h"
22 #include "src/gpu/graphite/GraphicsPipelineDesc.h"
23 #include "src/gpu/graphite/RenderPassDesc.h"
24 #include "src/gpu/graphite/ResourceProvider.h"
25 #include "tools/fonts/FontToolUtils.h"
26 #include "tools/graphite/UniqueKeyUtils.h"
27
28 #include <algorithm>
29 #include <random>
30 #include <thread>
31
32 using namespace::skgpu::graphite;
33
34 namespace {
35
36 static constexpr int kMaxNumStops = 9;
37 static constexpr SkColor gColors[kMaxNumStops] = {
38 SK_ColorRED,
39 SK_ColorGREEN,
40 SK_ColorBLUE,
41 SK_ColorCYAN,
42 SK_ColorMAGENTA,
43 SK_ColorYELLOW,
44 SK_ColorBLACK,
45 SK_ColorDKGRAY,
46 SK_ColorLTGRAY,
47 };
48 static constexpr SkPoint gPts[kMaxNumStops] = {
49 { -100.0f, -100.0f },
50 { -50.0f, -50.0f },
51 { -25.0f, -25.0f },
52 { -12.5f, -12.5f },
53 { 0.0f, 0.0f },
54 { 12.5f, 12.5f },
55 { 25.0f, 25.0f },
56 { 50.0f, 50.0f },
57 { 100.0f, 100.0f }
58 };
59 static constexpr float gOffsets[kMaxNumStops] =
60 { 0.0f, 0.125f, 0.25f, 0.375f, 0.5f, 0.625f, 0.75f, 0.875f, 1.0f };
61
linear(int numStops)62 std::pair<SkPaint, PaintOptions> linear(int numStops) {
63 SkASSERT(numStops <= kMaxNumStops);
64
65 PaintOptions paintOptions;
66 paintOptions.setShaders({ PrecompileShaders::LinearGradient() });
67 paintOptions.setBlendModes({ SkBlendMode::kSrcOver });
68
69 SkPaint paint;
70 paint.setShader(SkGradientShader::MakeLinear(gPts,
71 gColors, gOffsets, numStops,
72 SkTileMode::kClamp));
73 paint.setBlendMode(SkBlendMode::kSrcOver);
74
75 return { paint, paintOptions };
76 }
77
radial(int numStops)78 std::pair<SkPaint, PaintOptions> radial(int numStops) {
79 SkASSERT(numStops <= kMaxNumStops);
80
81 PaintOptions paintOptions;
82 paintOptions.setShaders({ PrecompileShaders::RadialGradient() });
83 paintOptions.setBlendModes({ SkBlendMode::kSrcOver });
84
85 SkPaint paint;
86 paint.setShader(SkGradientShader::MakeRadial(/* center= */ {0, 0}, /* radius= */ 100,
87 gColors, gOffsets, numStops,
88 SkTileMode::kClamp));
89 paint.setBlendMode(SkBlendMode::kSrcOver);
90
91 return { paint, paintOptions };
92 }
93
sweep(int numStops)94 std::pair<SkPaint, PaintOptions> sweep(int numStops) {
95 SkASSERT(numStops <= kMaxNumStops);
96
97 PaintOptions paintOptions;
98 paintOptions.setShaders({ PrecompileShaders::SweepGradient() });
99 paintOptions.setBlendModes({ SkBlendMode::kSrcOver });
100
101 SkPaint paint;
102 paint.setShader(SkGradientShader::MakeSweep(/* cx= */ 0, /* cy= */ 0,
103 gColors, gOffsets, numStops,
104 SkTileMode::kClamp,
105 /* startAngle= */ 0, /* endAngle= */ 359,
106 /* flags= */ 0, /* localMatrix= */ nullptr));
107 paint.setBlendMode(SkBlendMode::kSrcOver);
108
109 return { paint, paintOptions };
110 }
111
conical(int numStops)112 std::pair<SkPaint, PaintOptions> conical(int numStops) {
113 SkASSERT(numStops <= kMaxNumStops);
114
115 PaintOptions paintOptions;
116 paintOptions.setShaders({ PrecompileShaders::TwoPointConicalGradient() });
117 paintOptions.setBlendModes({ SkBlendMode::kSrcOver });
118
119 SkPaint paint;
120 paint.setShader(SkGradientShader::MakeTwoPointConical(/* start= */ {100, 100},
121 /* startRadius= */ 100,
122 /* end= */ {-100, -100},
123 /* endRadius= */ 100,
124 gColors, gOffsets, numStops,
125 SkTileMode::kClamp));
126 paint.setBlendMode(SkBlendMode::kSrcOver);
127
128 return { paint, paintOptions };
129 }
130
131 // The 12 comes from 4 types of gradient times 3 combinations (i.e., 4,8,N) for each one.
132 static constexpr int kNumDiffPipelines = 12;
133
134 typedef std::pair<SkPaint, PaintOptions> (*GradientCreationFunc)(int numStops);
135
136 struct Combo {
137 GradientCreationFunc fCreateOptionsMtd;
138 int fNumStops;
139 };
140
precompile_gradients(std::unique_ptr<PrecompileContext> precompileContext,bool permute,skiatest::Reporter *,int)141 void precompile_gradients(std::unique_ptr<PrecompileContext> precompileContext,
142 bool permute,
143 skiatest::Reporter* /* reporter */,
144 int /* threadID */) {
145 std::array<Combo, 4> combos;
146
147 // numStops doesn't influence the paintOptions
148 combos[0] = { linear, /* fNumStops= */ 2 };
149 combos[1] = { radial, /* fNumStops= */ 2 };
150 combos[2] = { sweep, /* fNumStops= */ 2 };
151 combos[3] = { conical, /* fNumStops= */ 2 };
152
153 if (permute) {
154 std::random_device rd;
155 std::mt19937 g(rd());
156
157 std::shuffle(combos.begin(), combos.end(), g);
158 }
159
160 const RenderPassProperties kProps = { DepthStencilFlags::kDepth,
161 kBGRA_8888_SkColorType,
162 /* dstColorSpace= */ nullptr,
163 /* requiresMSAA= */ false };
164
165 for (auto c : combos) {
166 auto [_, paintOptions] = c.fCreateOptionsMtd(c.fNumStops);
167 Precompile(precompileContext.get(),
168 paintOptions,
169 DrawTypeFlags::kBitmapText_Mask,
170 { &kProps, 1 });
171 }
172
173 precompileContext.reset();
174 }
175
purge_on_thread(std::unique_ptr<PrecompileContext> precompileContext,std::atomic_bool * keepLooping,skiatest::Reporter *,int)176 void purge_on_thread(std::unique_ptr<PrecompileContext> precompileContext,
177 std::atomic_bool* keepLooping,
178 skiatest::Reporter* /* reporter */,
179 int /* threadID */) {
180 const auto kSleepDuration = std::chrono::milliseconds(1);
181
182 while (*keepLooping) {
183 std::this_thread::sleep_for(kSleepDuration);
184
185 precompileContext->purgePipelinesNotUsedInMs(kSleepDuration);
186 }
187
188 precompileContext.reset();
189 }
190
191 // A simple helper to call Context::insertRecording on the Recordings generated on the
192 // recorder threads. It collects (and keeps ownership) of all the generated Recordings.
193 class Listener : public SkRefCnt {
194 public:
Listener(int numSenders)195 Listener(int numSenders) : fNumActiveSenders(numSenders) {}
196
addRecording(std::unique_ptr<Recording> recording)197 void addRecording(std::unique_ptr<Recording> recording) SK_EXCLUDES(fLock) {
198 {
199 SkAutoMutexExclusive lock(fLock);
200 fRecordings.push_back(std::move(recording));
201 }
202
203 fWorkAvailable.signal(1);
204 }
205
deregister()206 void deregister() SK_EXCLUDES(fLock) {
207 {
208 SkAutoMutexExclusive lock(fLock);
209 fNumActiveSenders--;
210 }
211
212 fWorkAvailable.signal(1);
213 }
214
insertRecordings(Context * context)215 void insertRecordings(Context* context) {
216 do {
217 fWorkAvailable.wait();
218 } while (this->insertRecording(context));
219 }
220
221 private:
222 // This entry point is run in a loop waiting on the 'fWorkAvailable' semaphore until there
223 // are no senders remaining (at which point it returns false) c.f. 'insertRecordings'.
insertRecording(Context * context)224 bool insertRecording(Context* context) SK_EXCLUDES(fLock) {
225 Recording* recording = nullptr;
226 int numSendersLeft;
227
228 {
229 SkAutoMutexExclusive lock(fLock);
230
231 numSendersLeft = fNumActiveSenders;
232
233 SkASSERT(fRecordings.size() >= fCurHandled);
234 if (fRecordings.size() > fCurHandled) {
235 recording = fRecordings[fCurHandled++].get();
236 }
237 }
238
239 if (recording) {
240 context->insertRecording({recording});
241 return true; // continue looping
242 }
243
244 return SkToBool(numSendersLeft); // continue looping if there are still active senders
245 }
246
247 SkMutex fLock;
248 SkSemaphore fWorkAvailable;
249
250 skia_private::TArray<std::unique_ptr<Recording>> fRecordings SK_GUARDED_BY(fLock);
251 int fCurHandled SK_GUARDED_BY(fLock) = 0;
252 int fNumActiveSenders SK_GUARDED_BY(fLock);
253 };
254
compile_gradients(std::unique_ptr<Recorder> recorder,sk_sp<Listener> listener,bool permute,skiatest::Reporter *,int)255 void compile_gradients(std::unique_ptr<Recorder> recorder,
256 sk_sp<Listener> listener,
257 bool permute,
258 skiatest::Reporter* /* reporter */,
259 int /* threadID */) {
260 std::array<Combo, kNumDiffPipelines> combos;
261
262 int i = 0;
263 for (auto createOptionsMtd : { linear, radial, sweep, conical }) {
264 for (int numStops: { 2, 7, kMaxNumStops }) {
265 combos[i++] = { createOptionsMtd, numStops };
266 }
267 }
268
269 if (permute) {
270 std::random_device rd;
271 std::mt19937 g(rd());
272
273 std::shuffle(combos.begin(), combos.end(), g);
274 }
275
276 SkFont font(ToolUtils::DefaultPortableTypeface(), /* size= */ 16);
277
278 const char text[] = "hambur1";
279 sk_sp<SkTextBlob> blob = SkTextBlob::MakeFromText(text, strlen(text), font);
280
281 SkImageInfo ii = SkImageInfo::Make(16, 16,
282 kBGRA_8888_SkColorType,
283 kPremul_SkAlphaType);
284
285 sk_sp<SkSurface> surf = SkSurfaces::RenderTarget(recorder.get(), ii,
286 skgpu::Mipmapped::kNo,
287 /* surfaceProps= */ nullptr);
288 SkCanvas* canvas = surf->getCanvas();
289
290 for (auto c : combos) {
291 auto [paint, _] = c.fCreateOptionsMtd(c.fNumStops);
292
293 canvas->drawTextBlob(blob, 0, 16, paint);
294
295 // This will trigger pipeline creation via TaskList::prepareResources
296 std::unique_ptr<skgpu::graphite::Recording> recording = recorder->snap();
297
298 listener->addRecording(std::move(recording));
299 }
300
301 listener->deregister();
302 }
303
run_test(Context * context,skiatest::Reporter * reporter,int numPurgingThreads,int numRecordingThreads,int numPrecompileThreads,bool permute)304 void run_test(Context* context,
305 skiatest::Reporter* reporter,
306 int numPurgingThreads,
307 int numRecordingThreads,
308 int numPrecompileThreads,
309 bool permute) {
310 const int totNumThreads = numPurgingThreads + numRecordingThreads + numPrecompileThreads;
311
312 sk_sp<Listener> listener;
313 if (numRecordingThreads) {
314 listener = sk_make_sp<Listener>(numRecordingThreads);
315 }
316
317 std::atomic_bool keepPurging = true; // controls the looping in the purging thread(s)
318
319 std::vector<std::thread> threads;
320 threads.reserve(totNumThreads);
321
322 int threadID = 0;
323 for (int i = 0; i < numPurgingThreads; ++i, ++threadID) {
324 std::unique_ptr<PrecompileContext> precompileContext = context->makePrecompileContext();
325
326 threads.push_back(std::thread(purge_on_thread,
327 std::move(precompileContext),
328 &keepPurging,
329 reporter,
330 threadID));
331 }
332 for (int i = 0; i < numRecordingThreads; ++i, ++threadID) {
333 std::unique_ptr<Recorder> recorder = context->makeRecorder();
334
335 threads.push_back(std::thread(compile_gradients,
336 std::move(recorder),
337 listener,
338 permute,
339 reporter,
340 threadID));
341 }
342 for (int i = 0; i < numPrecompileThreads; ++i, ++threadID) {
343 std::unique_ptr<PrecompileContext> precompileContext = context->makePrecompileContext();
344
345 threads.push_back(std::thread(precompile_gradients,
346 std::move(precompileContext),
347 permute,
348 reporter,
349 threadID));
350 }
351
352 // Process the work generated by the recording threads
353 if (listener) {
354 listener->insertRecordings(context);
355 }
356
357 keepPurging = false; // stop the loops in the purging thread(s)
358
359 for (auto& thread : threads) {
360 if (thread.joinable()) {
361 thread.join();
362 }
363 }
364
365 context->submit(SyncToCpu::kYes);
366 }
367
dump_stats(skgpu::BackendApi api,const GlobalCache::PipelineStats & stats)368 [[maybe_unused]] void dump_stats(skgpu::BackendApi api, const GlobalCache::PipelineStats& stats) {
369 SkDebugf("%s ------------------------------------------------------------------------------\n"
370 "CacheHits: %d\n"
371 "CacheMisses: %d\n"
372 "CacheAdditions: %d\n"
373 "Races: %d\n"
374 "Purges: %d\n",
375 BackendApiToStr(api),
376 stats.fGraphicsCacheHits,
377 stats.fGraphicsCacheMisses,
378 stats.fGraphicsCacheAdditions,
379 stats.fGraphicsRaces,
380 stats.fGraphicsPurges);
381 }
382
383 } // anonymous namespace
384
385 // This test precompiles all four flavors of gradient sequentially but on multiple
386 // threads with the goal of creating cache races.
DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompileTest,reporter,context,CtsEnforcement::kNever)387 DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompileTest,
388 reporter,
389 context,
390 CtsEnforcement::kNever) {
391 constexpr int kNumPurgingThreads = 0;
392 constexpr int kNumRecordingThreads = 0;
393 constexpr int kNumPrecompileThreads = 4;
394 constexpr bool kDontPermute = false;
395
396 run_test(context, reporter, kNumPurgingThreads, kNumRecordingThreads, kNumPrecompileThreads,
397 kDontPermute);
398
399 const GlobalCache::PipelineStats stats = context->priv().globalCache()->getStats();
400
401 // The 48 comes from:
402 // 4 gradient flavors (linear, radial, ...) *
403 // 3 types of each flavor (4, 8, N) *
404 // 4 precompile threads
405 REPORTER_ASSERT(reporter, stats.fGraphicsCacheHits + stats.fGraphicsCacheMisses == 48);
406 REPORTER_ASSERT(reporter, stats.fGraphicsCacheAdditions == kNumDiffPipelines);
407 REPORTER_ASSERT(reporter, stats.fGraphicsRaces > 0);
408 REPORTER_ASSERT(reporter, stats.fGraphicsPurges == 0);
409
410 REPORTER_ASSERT(reporter, stats.fGraphicsCacheMisses == stats.fGraphicsCacheAdditions +
411 stats.fGraphicsRaces);
412 }
413
414 // This test runs two threads compiling the gradient flavours and two threads
415 // pre-compiling the gradient flavors. This is to exercise the tracking of the
416 // various race combinations (i.e., Normal vs Precompile, Normal vs. Normal, etc.).
DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompileCompileTest,reporter,context,CtsEnforcement::kNever)417 DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompileCompileTest,
418 reporter,
419 context,
420 CtsEnforcement::kNever) {
421 constexpr int kNumPurgingThreads = 0;
422 constexpr int kNumRecordingThreads = 2;
423 constexpr int kNumPrecompileThreads = 2;
424 constexpr bool kDontPermute = false;
425
426 run_test(context, reporter, kNumPurgingThreads, kNumRecordingThreads, kNumPrecompileThreads,
427 kDontPermute);
428
429 const GlobalCache::PipelineStats stats = context->priv().globalCache()->getStats();
430
431 // The 48 comes from:
432 // 4 gradient flavors (linear, radial, ...) *
433 // 3 types of each flavor (4, 8, N) *
434 // (2 normal-compile threads + 2 pre-compile threads)
435 REPORTER_ASSERT(reporter, stats.fGraphicsCacheHits + stats.fGraphicsCacheMisses == 48);
436 REPORTER_ASSERT(reporter, stats.fGraphicsCacheAdditions == kNumDiffPipelines);
437 REPORTER_ASSERT(reporter, stats.fGraphicsRaces > 0);
438 REPORTER_ASSERT(reporter, stats.fGraphicsPurges == 0);
439
440 REPORTER_ASSERT(reporter, stats.fGraphicsCacheMisses == stats.fGraphicsCacheAdditions +
441 stats.fGraphicsRaces);
442 }
443
444 // This test compiles the gradient flavors on a thread and then tests out the time-based
445 // purging.
DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelineCompilePurgingTest,reporter,context,CtsEnforcement::kNever)446 DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelineCompilePurgingTest,
447 reporter,
448 context,
449 CtsEnforcement::kNever) {
450 constexpr int kNumPurgingThreads = 0;
451 constexpr int kNumRecordingThreads = 1;
452 constexpr int kNumPrecompileThreads = 0;
453 constexpr bool kDontPermute = false;
454
455 std::unique_ptr<PrecompileContext> precompileContext = context->makePrecompileContext();
456
457 auto begin = std::chrono::steady_clock::now();
458
459 run_test(context, reporter, kNumPurgingThreads, kNumRecordingThreads, kNumPrecompileThreads,
460 kDontPermute);
461
462 auto end = std::chrono::steady_clock::now();
463
464 auto deltaMS = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
465
466 precompileContext->purgePipelinesNotUsedInMs(2*deltaMS);
467
468 GlobalCache::PipelineStats stats = context->priv().globalCache()->getStats();
469
470 REPORTER_ASSERT(reporter, stats.fGraphicsCacheHits == 0);
471 REPORTER_ASSERT(reporter, stats.fGraphicsCacheMisses == kNumDiffPipelines);
472 REPORTER_ASSERT(reporter, stats.fGraphicsCacheAdditions == kNumDiffPipelines);
473 REPORTER_ASSERT(reporter, stats.fGraphicsRaces == 0);
474 // Every created Pipeline should've been used since the start of this test
475 REPORTER_ASSERT(reporter, stats.fGraphicsPurges == 0, "num purges: %d", stats.fGraphicsPurges);
476
477 //--------------------------------------------------------------------------------------------
478 const auto kSleepDuration = std::chrono::milliseconds(1);
479
480 std::this_thread::sleep_for(kSleepDuration);
481
482 precompileContext->purgePipelinesNotUsedInMs(kSleepDuration);
483
484 stats = context->priv().globalCache()->getStats();
485
486 REPORTER_ASSERT(reporter, stats.fGraphicsCacheHits == 0);
487 REPORTER_ASSERT(reporter, stats.fGraphicsCacheMisses == kNumDiffPipelines);
488 REPORTER_ASSERT(reporter, stats.fGraphicsCacheAdditions == kNumDiffPipelines);
489 REPORTER_ASSERT(reporter, stats.fGraphicsRaces == 0);
490 // None of the created Pipelines should've been used since we started to sleep - so they
491 // all get purged.
492 REPORTER_ASSERT(reporter, stats.fGraphicsPurges == kNumDiffPipelines);
493 }
494
495 // This test *precompiles* the gradient flavors on a thread and then tests out the time-based
496 // purging.
DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompilePurgingTest,reporter,context,CtsEnforcement::kNever)497 DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompilePurgingTest,
498 reporter,
499 context,
500 CtsEnforcement::kNever) {
501 constexpr int kNumPurgingThreads = 0;
502 constexpr int kNumRecordingThreads = 0;
503 constexpr int kNumPrecompileThreads = 1;
504 constexpr bool kDontPermute = false;
505
506 std::unique_ptr<PrecompileContext> precompileContext = context->makePrecompileContext();
507
508 auto begin = std::chrono::steady_clock::now();
509
510 run_test(context, reporter, kNumPurgingThreads, kNumRecordingThreads, kNumPrecompileThreads,
511 kDontPermute);
512
513 auto end = std::chrono::steady_clock::now();
514
515 auto deltaMS = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
516
517 precompileContext->purgePipelinesNotUsedInMs(deltaMS);
518
519 GlobalCache::PipelineStats stats = context->priv().globalCache()->getStats();
520
521 REPORTER_ASSERT(reporter, stats.fGraphicsCacheHits == 0);
522 REPORTER_ASSERT(reporter, stats.fGraphicsCacheMisses == kNumDiffPipelines);
523 REPORTER_ASSERT(reporter, stats.fGraphicsCacheAdditions == kNumDiffPipelines);
524 REPORTER_ASSERT(reporter, stats.fGraphicsRaces == 0);
525 // Precompilation doesn't count as a use so all the Pipelines will be purged even though
526 // they were created w/in 'deltaMS'
527 REPORTER_ASSERT(reporter, stats.fGraphicsPurges == kNumDiffPipelines);
528 }
529
530 // This test fires off two compilation threads, two precompilation threads and one
531 // purging thread. This is intended to stress test the Pipeline cache's thread safety and
532 // the purging behavior.
DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompileCompilePurgingTest,reporter,context,CtsEnforcement::kNever)533 DEF_GRAPHITE_TEST_FOR_ALL_CONTEXTS(ThreadedPipelinePrecompileCompilePurgingTest,
534 reporter,
535 context,
536 CtsEnforcement::kNever) {
537 constexpr int kNumPurgingThreads = 1;
538 constexpr int kNumRecordingThreads = 2;
539 constexpr int kNumPrecompileThreads = 2;
540 constexpr bool kPermute = true;
541
542 run_test(context, reporter, kNumPurgingThreads, kNumRecordingThreads, kNumPrecompileThreads,
543 kPermute);
544
545 GlobalCache::PipelineStats stats = context->priv().globalCache()->getStats();
546
547 // The 48 comes from:
548 // 4 gradient flavors (linear, radial, ...) *
549 // 3 types of each flavor (4, 8, N) *
550 // (2 normal-compile threads + 2 pre-compile threads)
551 REPORTER_ASSERT(reporter, stats.fGraphicsCacheHits + stats.fGraphicsCacheMisses == 48);
552 REPORTER_ASSERT(reporter, stats.fGraphicsCacheMisses == stats.fGraphicsCacheAdditions +
553 stats.fGraphicsRaces);
554 // Purges can force recreation of a Pipeline
555 REPORTER_ASSERT(reporter, stats.fGraphicsCacheAdditions >= kNumDiffPipelines);
556 REPORTER_ASSERT(reporter, stats.fGraphicsRaces > 0);
557 }
558
559 #endif // SK_GRAPHITE
560