Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Profiling your PHP Application (PHPSC16)

Profiling your PHP Application (PHPSC16)

So, you’ve been through and changed all your double quotes to single quotes but your application still isn’t running at the speed of light. What’s going on?

Making an application scale is generally seen as something that only the most magical of developers can do, but it’s easy once you have the correct tools. Fortunately for us, these tools are freely available online!

In this talk, we’ll take a look at a few options that we have available to work out what our application is actually doing, help identify bottlenecks and fix them so that we can move on to the more important part of the project: delivering features.

Michael Heap

June 11, 2016
Tweet

More Decks by Michael Heap

Other Decks in Technology

Transcript

  1. Profiling PHP
    Michael Heap (@mheap)
    Developer at DataSift
    Presented at PHP South Coast, June 2016

    View Slide

  2. Me!
    I’m Michael
    I’m @mheap
    Developer at DataSift

    View Slide

  3. @mheap
    https://joind.in/18028

    View Slide

  4. Disclaimer

    View Slide

  5. Profiling PHP

    View Slide

  6. Profiling Applications

    View Slide

  7. We Won’t Cover
    Frontend optimisation
    Database queries
    TCP Negotiation

    View Slide

  8. We Will Cover
    Quick wins
    Naive profiling
    XHProf
    XHGUI
    Other considerations

    View Slide

  9. Quick Wins
    Things to do before you start profiling

    View Slide

  10. $ php --go-faster

    View Slide

  11. $ php --go-faster
    $ apt-get upgrade php
    $ yum upgrade php
    $ pacman -S php

    View Slide

  12. Setting the scene

    View Slide

  13. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }

    View Slide

  14. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”, “interaction.author.name”]

    View Slide

  15. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }

    View Slide

  16. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }

    View Slide

  17. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }

    View Slide

  18. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }

    View Slide

  19. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }

    View Slide

  20. [“interaction.content”]
    {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }

    View Slide

  21. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”]

    View Slide

  22. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”]

    View Slide

  23. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”]

    View Slide

  24. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”]

    View Slide

  25. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”]

    View Slide

  26. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”, “interaction.author.name”]

    View Slide

  27. {
    "interaction": {
    "author": {
    "id": 115193554,
    "link": "http://hooliganstsar.tumblr.com/",
    "username": "hooliganstsar"
    },
    "content": "irhinoceri:\nWhen people try to discount Padme’s love for Anakin they completely ignore the fact that she says “stop come back I love you” even after she realizes that he’s committed murder and that he has delusions of grandeur where they become co-dictators.\nSure, it’s a desperate, emotional plea and not a promise that everything will return to normal. Not saying that. What I am saying is that she still loved him and believed there was
    good in him even after he destroyed the very republic that s...",
    "created_at": "Thu, 26 May 2016 15:12:31 +0000",
    "id": "1e6235444361a9808d6a8de10491c475",
    "link": "http://hooliganstsar.tumblr.com/post/144958725235",
    "media_type": "text",
    "received_at": 1464275553.0978,
    "schema": {
    "version": 3
    },
    "subtype": "post",
    "type": "tumblr"
    },
    "language": {
    "tag": "en",
    "tag_extended": "en",
    "confidence": 99
    },
    "salience": {
    "content": {
    "entities": [{
    "name": "Padme",
    "type": "Person",
    "label": "Person",
    "sentiment": -8,
    "evidence": 7,
    "confident": 1,
    "about": 1,
    "themes": ["flawed human", "committed murder", "n’t problematic", "justifiably upset", "Dark Side Anakin", "bad meta", "unemotional level", "space feminist", "true reason", "sentient beings", "including children", "political views cause"]
    }, {
    "name": "Obi Wan",
    "type": "Person",
    "label": "Person",
    "sentiment": 5,
    "evidence": 6,
    "confident": 1,
    "about": 0,
    "themes": ["loved Anakin", "Obikin shippers"]
    }, {
    "name": "“stop come back I love you”",
    "type": "Quote",
    "label": "Quote",
    "sentiment": 6,
    "evidence": 1,
    "confident": 1,
    "about": 0,
    "themes": ["committed murder", "emotional plea"]
    }, {
    "name": "“well I can respect this woman only if she loves the political system more than the person.”",
    "type": "Quote",
    "label": "Quote",
    "sentiment": 4,
    "evidence": 1,
    "confident": 1,
    "about": 0,
    "themes": ["political system", "problematic husband ffs", "flawed human", "n’t problematic"]
    }, {
    "name": "“space feminist”",
    "type": "Quote",
    "label": "Quote",
    "sentiment": 0,
    "evidence": 1,
    "confident": 1,
    "about": 0,
    "themes": ["true reason", "bad meta", "unemotional level", "space feminist", "sentient beings", "including children"]
    }, {
    "name": "“more.”",
    "type": "Quote",
    "label": "Quote",
    "sentiment": 0,
    "evidence": 1,
    "confident": 1,
    "about": 0,
    "themes": ["different motivations"]
    }],
    "sentiment": 0,
    "topics": [{
    "name": "Crime",
    "hits": 0,
    "score": 0.66876006126404,
    "additional": "irhinoceri: When people try to discount Padme’s love for Anakin they completely ignore the fact that she says “stop come back I love you” even after she realizes that he’s committed murder and that he has delusions of grandeur where they become co-dictators. It’s not like the republic wasn’t problematic itself so basically you’re pitting a flawed human being against a flawed political
    system and saying “well I can respect this woman only if she loves the political system more than the person.” T..."
    }]
    }
    },
    "tumblr": {
    "action": "create",
    "activity": "post",
    "blog": {
    "id": "115193554",
    "is_group_blog": false,
    "name": "hooliganstsar",
    "url": "http://hooliganstsar.tumblr.com/"
    },
    "blog_name": "hooliganstsar",
    "blogid": "115193554",
    "body": "irhinoceri:\n\nWhen people try to discount Padme’s love for Anakin they completely ignore the fact that she says “stop come back I love you” even after she realizes that he’s committed murder and that he has delusions of grandeur where they become co-dictators.\nSure, it’s a desperate, emotional plea and not a promise that everything will return to normal. Not saying that. What I am saying is that she still loved him and believed there was
    good in him even after he destroyed the very republic that ...",
    "created_at": "Thu, 26 May 2016 15:12:31 +0000",
    "format": "html",
    "id": "000533c038212bbbfd64395be146f382",
    "is_submission": false,
    "note_count": 397,
    "post": {
    "id": "144958725235",
    "url": "http://hooliganstsar.tumblr.com/post/144958725235"
    },
    "post_url": "http://hooliganstsar.tumblr.com/post/144958725235",
    "postid": "144958725235",
    "reblogged": {
    "parent": {
    "blogid": "44626114",
    "id": "144958505451",
    "name": "imaginal",
    "url": "http://imaginal.tumblr.com/post/144958505451"
    },
    "root": {
    "blogid": "23604722",
    "id": "140021104712",
    "name": "irhinoceri",
    "url": "http://irhinoceri.tumblr.com/post/140021104712"
    },
    "source": {
    "blogid": "115193554"
    }
    },
    "short_url": "https://tmblr.co/ZgfWkr270Dfnp",
    "slug": "irhinoceri-when-people-try-to-discount-padmes",
    "source_title": "irhinoceri",
    "source_url": "http://irhinoceri.tumblr.com/post/140021104712/when-people-try-to-discount-padmes-love-for",
    "type": "text",
    "url": "https://tmblr.co/ZgfWkr270Dfnp"
    }
    }

    View Slide

  28. Time limited
    Resource limited
    Fixed amount of data

    View Slide

  29. Test harness

    View Slide

  30. Example data

    View Slide

  31. $start = microtime(true);
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    $tmp = json_decode($line, true);
    if ($tmp) { $data[] = $tmp; }
    }
    echo "Took: ". (microtime(true) - $start);

    View Slide

  32. $start = microtime(true);
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    $tmp = json_decode($line, true);
    if ($tmp) { $data[] = $tmp; }
    }
    echo "Took: ". (microtime(true) - $start);

    View Slide

  33. $start = microtime(true);
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    $tmp = json_decode($line, true);
    if ($tmp) { $data[] = $tmp; }
    }
    echo "Took: ". (microtime(true) - $start);

    View Slide

  34. $start = microtime(true);
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    $tmp = json_decode($line, true);
    if ($tmp) { $data[] = $tmp; }
    }
    echo "Took: ". (microtime(true) - $start);

    View Slide

  35. $start = microtime(true);
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    $tmp = json_decode($line, true);
    if ($tmp) { $data[] = $tmp; }
    }
    echo "Took: ". (microtime(true) - $start);

    View Slide

  36. Run Times
    50,000 items: 5.7440850734711
    50,000 items: 5.8537809848785
    50,000 items: 5.5094730854034
    50,000 items: 5.8217489719391
    50,000 items: 5.8287329673767

    View Slide

  37. $start = microtime(true);
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    $tmp = json_decode($line, true);
    if ($tmp) { $data[] = $tmp; }
    }
    echo "Took: ". (microtime(true) - $start);

    View Slide

  38. $start = microtime(true);
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    $tmp = json_decode($line, true);
    if ($tmp) { $data[] = $tmp; }
    }
    echo "Took: ". (microtime(true) - $start);

    View Slide

  39. if (file_exists("./cache")){
    $data = json_decode(
    file_get_contents("./cache"), true
    );
    } else {
    $input = file_get_contents('./small.json');
    foreach (explode("\n", $input) as $line){
    // Decode and add to array
    }
    file_put_contents("./cache",
    json_encode($data)
    );
    }

    View Slide

  40. Run Times
    Without Cache: 11.717740058899
    With Cache: 8.7964880466461

    View Slide

  41. Guessing

    View Slide

  42. XHProf
    sudo pecl install xhprof-0.9.4
    extension=xhprof.so
    /usr/share/php/xhprof_lib

    View Slide

  43. xhprof_enable(
    XHPROF_FLAGS_CPU +
    XHPROF_FLAGS_MEMORY
    )

    View Slide

  44. $data = xhprof_disable();
    print_r($data);

    View Slide

  45. [main()==>json_decode] => Array
    (
    [ct] => 50001
    [wt] => 4379326
    [cpu] => 4710383
    [mu] => 807834192
    [pmu] => 807572808
    )

    View Slide

  46. include_once '/usr/share/php/xhprof_lib/utils/
    xhprof_lib.php';
    include_once '/usr/share/php/xhprof_lib/utils/
    xhprof_runs.php';
    $runs = new XHProfRuns_Default();
    $runs->save_run($xhprof_data, "michael");

    View Slide

  47. No Cache

    View Slide

  48. Total Incl. Wall Time (microsec): 12,638,896 microsecs
    Total Incl. CPU (microsecs): 9,692,792 microsecs
    Total Incl. MemUse (bytes): 929,483,336 bytes
    Total Incl. PeakMemUse (bytes): 1,170,441,880 bytes
    Number of Function Calls: 50,008
    No Cache

    View Slide

  49. Total Incl. Wall Time (microsec): 9,473,467 microsecs
    Total Incl. CPU (microsecs): 7,010,122 microsecs
    Total Incl. MemUse (bytes): 815,163,360 bytes
    Total Incl. PeakMemUse (bytes): 935,510,968 bytes
    Number of Function Calls: 5
    Cache

    View Slide

  50. No Cache

    View Slide

  51. No Cache

    View Slide

  52. No Cache

    View Slide

  53. No Cache
    Cache

    View Slide

  54. Back to the task

    View Slide

  55. {
    “interaction”: {
    “content": "Hello World”,
    ”author”: {
    “name”: ”Michael"
    }
    }
    }
    [“interaction.content”, “interaction.author.name”]

    View Slide

  56. Version 1

    View Slide

  57. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    $keys = array_merge($keys, $this->generate($v,
    array_merge($parents, $newFields)));
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  58. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    $keys = array_merge($keys, $this->generate($v,
    array_merge($parents, $newFields)));
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  59. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    $keys = array_merge($keys, $this->generate($v,
    array_merge($parents, $newFields)));
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  60. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    $keys = array_merge($keys, $this->generate($v,
    array_merge($parents, $newFields)));
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  61. public function generateAll($interactions) {
    $all = [];
    foreach ($interactions as $i) {
    $all = array_merge($all, $this->generate($i));
    }
    return array_filter(array_unique($all));
    }

    View Slide

  62. public function generateAll($interactions) {
    $all = [];
    foreach ($interactions as $i) {
    $all = array_merge($all, $this->generate($i));
    }
    return array_filter(array_unique($all));
    }

    View Slide

  63. public function generateAll($interactions) {
    $all = [];
    foreach ($interactions as $i) {
    $all = array_merge($all, $this->generate($i));
    }
    return array_filter(array_unique($all));
    }

    View Slide

  64. Total Incl. Wall Time (microsec): 8,046,823 microsecs
    Total Incl. CPU (microsecs): 8,018,211 microsecs
    Total Incl. MemUse (bytes): 19,617,760 bytes
    Total Incl. PeakMemUse (bytes): 37,543,384 bytes
    Number of Function Calls: 355,764

    View Slide

  65. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    $keys = array_merge($keys, $this->generate($v,
    array_merge($parents, $newFields)));
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  66. View Slide

  67. View Slide

  68. Version 2

    View Slide

  69. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    $keys = array_merge($keys, $this->generate($v,
    array_merge($parents, $newFields)));
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  70. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    foreach ($this->generate($v, array_merge($parents, $newFields)
    as $val) { $keys[] = $val; }
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  71. public function generateAll($interactions) {
    $all = [];
    foreach ($interactions as $i) {
    $all = array_merge($all, $this->generate($i));
    }
    return array_filter(array_unique($all));
    }

    View Slide

  72. public function generateAll($interactions) {
    $all = [];
    foreach ($interactions as $i) {
    foreach ($this->generate($i) as $g){
    $all[] = $g;
    }
    }
    return array_filter(array_unique($all));
    }

    View Slide

  73. Total Incl. Wall Time (microsec): 3,617,157 microsecs
    Total Incl. CPU (microsecs): 3,539,472 microsecs
    Total Incl. MemUse (bytes): 20,135,232 bytes
    Total Incl. PeakMemUse (bytes): 37,407,936 bytes
    Number of Function Calls: 334,278

    View Slide

  74. View Slide

  75. View Slide

  76. Version 2.5

    View Slide

  77. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if ($v instanceof Traversable) {
    foreach ($this->generate($v, array_merge($parents, $newFields)
    as $val) { $keys[] = $val; }
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  78. Total Incl. Wall Time (microsec): 274,998 microsecs
    Total Incl. CPU (microsecs): 221,910 microsecs
    Total Incl. MemUse (bytes): 19,510,464 bytes
    Total Incl. PeakMemUse (bytes): 22,173,048 bytes
    Number of Function Calls: 10,922

    View Slide

  79. Version 3

    View Slide

  80. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (is_array($v) || is_object($v)) {
    foreach ($this->generate($v, array_merge($parents, $newFields)
    as $val) { $keys[] = $val; }
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  81. public function generate($interaction, $parents = []) {
    $keys = [];
    foreach ($interaction as $k => $v) {
    $newFields = [];
    if (!is_numeric($k)) {
    $newFields[] = $k;
    }
    if (!is_scalar($v)) {
    foreach ($this->generate($v, array_merge($parents, $newFields)
    as $val) { $keys[] = $val; }
    } else {
    $keys[] = implode(array_merge($parents ,$newFields), ".");
    }
    }
    return $keys;
    }

    View Slide

  82. Total Incl. Wall Time (microsec): 2,926,818 microsecs
    Total Incl. CPU (microsecs): 2,857,804 microsecs
    Total Incl. MemUse (bytes): 20,126,960 bytes
    Total Incl. PeakMemUse (bytes): 37,362,384 bytes
    Number of Function Calls: 284,214

    View Slide

  83. Total Incl. Wall Time (microsec): 3,617,157 microsecs
    Total Incl. CPU (microsecs): 3,539,472 microsecs
    Total Incl. MemUse (bytes): 20,135,232 bytes
    Total Incl. PeakMemUse (bytes): 37,407,936 bytes
    Number of Function Calls: 334,278

    View Slide

  84. Total Incl. Wall Time (microsec): 2,926,818 microsecs
    Total Incl. CPU (microsecs): 2,857,804 microsecs
    Total Incl. MemUse (bytes): 20,126,960 bytes
    Total Incl. PeakMemUse (bytes): 37,362,384 bytes
    Number of Function Calls: 284,214

    View Slide

  85. Version 4

    View Slide

  86. Total Incl. Wall Time (microsec): 8,046,823 microsecs
    Total Incl. CPU (microsecs): 8,018,211 microsecs
    Total Incl. MemUse (bytes): 19,617,760 bytes
    Total Incl. PeakMemUse (bytes): 37,543,384 bytes
    Number of Function Calls: 355,764

    View Slide

  87. Total Incl. Wall Time (microsec): 1,274,903 microsecs
    Total Incl. CPU (microsecs): 1,195,136 microsecs
    Total Incl. MemUse (bytes): 20,086,232 bytes
    Total Incl. PeakMemUse (bytes): 34,597,576 bytes
    Number of Function Calls: 93,053

    View Slide

  88. $currentDepth = [];
    foreach($parents as $x){ $currentDepth[] = $x; }
    foreach($newFields as $x){ $currentDepth[] = $x; }

    View Slide

  89. $xx = \gettype($v)[0];
    if ($xx == 'a' || $xx == 'o') {

    View Slide

  90. XHProf UI

    View Slide

  91. View Slide

  92. View Slide

  93. View Slide

  94. View Slide

  95. XHGui

    View Slide

  96. View Slide

  97. View Slide

  98. View Slide

  99. View Slide

  100. View Slide

  101. View Slide

  102. blackfire.io

    View Slide

  103. View Slide

  104. View Slide

  105. View Slide

  106. View Slide

  107. View Slide

  108. View Slide

  109. View Slide

  110. View Slide

  111. Production friendly

    View Slide

  112. Read more
    https://www.colinodell.com/blog/2015-11/
    optimizing-league-commonmark-blackfire-io

    View Slide

  113. XDebug/Cachegrind
    tideways.io
    New Relic
    Zend Z-Ray
    PHP Bench
    Other Tools

    View Slide

  114. Observer effect

    View Slide

  115. Optimisation

    View Slide

  116. Caching
    The fastest code is code that doesn't run

    View Slide

  117. Should it run?
    The fastest code is code that doesn't run

    View Slide

  118. Async Workers
    Gearman
    Beanstalkd
    RabbitMQ
    ZeroMQ

    View Slide

  119. Logging
    Work out which code paths are popular
    Optimise them

    View Slide

  120. Measuring Production
    Enable 1 in 1000 requests with XHProf
    Blackfire production mode (with header)

    View Slide

  121. Used/available memory
    Response time
    Timeout
    Successful/failed requests
    Open socket count
    Bandwidth usage
    Track Everything

    View Slide

  122. Profile Everything
    Framework bootstrapping
    Business logic
    Unit tests

    View Slide

  123. Measure
    Refactor
    Measure again
    Process

    View Slide

  124. “Without measurements,
    you’re just guessing”

    View Slide

  125. Is it worth it?

    View Slide

  126. Usually, no

    View Slide

  127. View Slide

  128. Thanks!
    I’ve been @mheap, you’ve been awesome.
    Please leave feedback on Joind.in
    https://joind.in/18028

    View Slide