diff --git a/devel/Containerfile b/devel/Containerfile new file mode 100644 index 0000000..6a264d3 --- /dev/null +++ b/devel/Containerfile @@ -0,0 +1,14 @@ +FROM php:8.4.14-apache + +# Install Xdebug for performance profiling +RUN pecl install xdebug-3.5.0 && \ + docker-php-ext-enable xdebug + +# Configure Xdebug for profiling +RUN echo "xdebug.mode=develop,profile" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + echo "xdebug.output_dir=/tmp" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + echo "xdebug.start_with_request=trigger" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + echo "xdebug.profiler_output_name=cachegrind.out.%p" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + +# Note: To trigger profiling, add ?XDEBUG_PROFILE=1 to any URL +# Profile files will be generated in /tmp/ inside the container diff --git a/devel/compose.yaml b/devel/compose.yaml index 132c6bb..0103721 100644 --- a/devel/compose.yaml +++ b/devel/compose.yaml @@ -15,7 +15,7 @@ services: # - "4040:80" # command: bash -c "a2enconf custom && a2enmod rewrite && apache2-foreground" default: - image: php:8.4.14-apache + build: ./ container_name: folderweb-default working_dir: /var/www/html/ volumes: diff --git a/devel/perf.sh b/devel/perf.sh new file mode 100755 index 0000000..239dc20 --- /dev/null +++ b/devel/perf.sh @@ -0,0 +1,323 @@ +#!/bin/bash +# FolderWeb Performance Testing Tool +# All-in-one script for profiling, test data generation, and analysis + +set -e + +CONTAINER="${CONTAINER:-folderweb-default}" +PORT="${PORT:-8080}" +ANALYZER="/tmp/analyze_profile.php" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +show_help() { + cat << EOF +FolderWeb Performance Testing Tool + +SETUP (First Time): + cd devel && podman-compose build && podman-compose up -d + +PROFILING COMMANDS: + profile Profile a URL (add ?XDEBUG_PROFILE=1 and analyze) + analyze [file] Analyze a cachegrind file (latest if not specified) + list List all available cachegrind files + clean Remove all cachegrind profile files + +TEST DATA COMMANDS: + generate Generate synthetic test data in /tmp/test-content + Sizes: small (~100 posts), medium (~500), large (~1500), huge (~5000+) + generate custom N M D Custom: N categories, M posts/cat, D depth + testdata-stats Show test data statistics + testdata-clean Remove test data from container + +USING TEST DATA: + Test data is generated at /tmp/test-content inside the container. + + Option 1 - Symlink (quick): + podman exec folderweb-default ln -s /tmp/test-content /var/www/html + + Option 2 - Edit router.php (temporary): + Change \$ctx->contentDir to '/tmp/test-content' + +EXAMPLES: + $0 profile / # Profile homepage + $0 generate medium # Generate ~500 posts + $0 testdata-stats # View stats + $0 profile /blog-321/ # Profile test data + + # Stress test with huge dataset + $0 generate huge && $0 profile / + + # Export for KCachegrind + podman cp folderweb-default:/tmp/cachegrind.out.123 ./profile.out + kcachegrind ./profile.out + +ENVIRONMENT VARIABLES: + CONTAINER=name Target container (default: folderweb-default) + PORT=port Target port (default: 8080) + +RESULTS: + Focus on functions taking >5% of execution time. + Time (ms) = total time spent | Memory (KB) = total allocated + Calls = invocation count | Percentage = % of total time +EOF +} + +ensure_analyzer() { + if ! podman exec "$CONTAINER" test -f "$ANALYZER" 2>/dev/null; then + echo -e "${YELLOW}Installing profile analyzer...${NC}" >&2 + podman exec "$CONTAINER" bash -c "cat > $ANALYZER" << 'EOFANALYZER' +\n"; exit(1); } +$file = $argv[1]; +if (!file_exists($file)) { echo "Error: File not found: $file\n"; exit(1); } + +$lines = file($file); +$functions = []; +$currentFile = ''; +$currentFunction = ''; + +foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, 'fl=')) { + preg_match('/fl=\(?\d*\)?\s*(.+)/', $line, $matches); + $currentFile = $matches[1] ?? ''; + } elseif (str_starts_with($line, 'fn=')) { + preg_match('/fn=\(?\d*\)?\s*(.+)/', $line, $matches); + $currentFunction = $matches[1] ?? ''; + } elseif (preg_match('/^(\d+)\s+(\d+)\s+(\d+)/', $line, $matches)) { + $time = (int)$matches[2]; + $memory = (int)$matches[3]; + if ($currentFunction && $time > 0) { + if (!isset($functions[$currentFunction])) { + $functions[$currentFunction] = ['name' => $currentFunction, 'file' => $currentFile, 'time' => 0, 'memory' => 0, 'calls' => 0]; + } + $functions[$currentFunction]['time'] += $time; + $functions[$currentFunction]['memory'] += $memory; + $functions[$currentFunction]['calls']++; + } + } +} + +usort($functions, fn($a, $b) => $b['time'] <=> $a['time']); +echo "\n=== TOP 20 SLOWEST FUNCTIONS ===\n\n"; +echo str_pad("Time (ms)", 12) . str_pad("Memory (KB)", 14) . str_pad("Calls", 8) . "Function\n"; +echo str_repeat("-", 100) . "\n"; + +$total_time = array_sum(array_column($functions, 'time')); +foreach (array_slice($functions, 0, 20) as $fn) { + $time_ms = round($fn['time'] / 100000, 2); + $memory_kb = round($fn['memory'] / 1024, 2); + $percentage = round(($fn['time'] / $total_time) * 100, 1); + $funcName = strlen($fn['name']) > 60 ? substr($fn['name'], 0, 57) . '...' : $fn['name']; + echo str_pad($time_ms, 12) . str_pad($memory_kb, 14) . str_pad($fn['calls'], 8) . $funcName . " ({$percentage}%)\n"; + if ($fn['file'] && $fn['file'] !== 'php:internal') { + echo str_repeat(" ", 34) . "└─ {$fn['file']}\n"; + } +} +echo "\nTotal execution time: " . round($total_time / 100000, 2) . " ms\n\n"; +EOFANALYZER + fi +} + +cmd_profile() { + local url="${1:-/}" + echo -e "${GREEN}Profiling: $url (container: $CONTAINER, port: $PORT)${NC}" + + local response=$(curl -s -I "http://localhost:${PORT}${url}?XDEBUG_PROFILE=1") + local profile_file=$(echo "$response" | grep -i "X-Xdebug-Profile-Filename" | cut -d' ' -f2 | tr -d '\r') + + if [ -z "$profile_file" ]; then + echo -e "${RED}Error: No profile generated. Is Xdebug installed?${NC}" + echo -e "${YELLOW}Tip: Rebuild container with: cd devel && podman-compose build${NC}" + exit 1 + fi + + echo -e "${GREEN}Profile generated: $profile_file${NC}" + ensure_analyzer + echo "" + podman exec "$CONTAINER" php "$ANALYZER" "$profile_file" +} + +cmd_analyze() { + local file="$1" + ensure_analyzer + + if [ -z "$file" ]; then + file=$(podman exec "$CONTAINER" sh -c 'ls -t /tmp/cachegrind.out.* 2>/dev/null | head -1') + if [ -z "$file" ]; then + echo -e "${RED}No cachegrind files found. Run a profile first.${NC}" + exit 1 + fi + echo -e "${YELLOW}Analyzing: $file${NC}" + fi + + podman exec "$CONTAINER" php "$ANALYZER" "$file" +} + +cmd_list() { + echo -e "${GREEN}Available cachegrind profiles in $CONTAINER:${NC}" + if ! podman exec "$CONTAINER" sh -c 'ls -lh /tmp/cachegrind.out.* 2>/dev/null'; then + echo -e "${YELLOW}No profile files found.${NC}" + fi +} + +cmd_clean() { + echo -e "${YELLOW}Removing cachegrind files from $CONTAINER...${NC}" + podman exec "$CONTAINER" sh -c 'rm -f /tmp/cachegrind.out.*' 2>/dev/null || true + echo -e "${GREEN}Done!${NC}" +} + +cmd_generate() { + local size="${1:-medium}" + local categories posts depth + + case "$size" in + small) categories=5; posts=20; depth=2 ;; + medium) categories=10; posts=50; depth=3 ;; + large) categories=15; posts=100; depth=3 ;; + huge) categories=25; posts=200; depth=4 ;; + custom) categories=${2:-10}; posts=${3:-50}; depth=${4:-3} ;; + *) echo -e "${RED}Unknown size. Use: small, medium, large, huge, custom${NC}"; exit 1 ;; + esac + + echo -e "${GREEN}Generating $size test data in $CONTAINER:/tmp/test-content${NC}" + echo -e "${BLUE}Config: $categories categories, $posts posts/category, $depth levels${NC}" + + # Create generator script in container + cat << 'EOFGEN' | podman exec -i "$CONTAINER" tee /tmp/gen.php > /dev/null + $maxDepth) return; + + $numCategories = $depth === 0 ? 10 : rand(3, 8); + for ($c = 0; $c < $numCategories; $c++) { + $categoryName = $categories[array_rand($categories)] . '-' . rand(100, 999); + $categoryPath = $baseDir . $parentPath . '/' . $categoryName; + if (!is_dir($categoryPath)) mkdir($categoryPath, 0755, true); + + $categoryTitle = ucwords(str_replace('-', ' ', $categoryName)); + file_put_contents("$categoryPath/metadata.ini", generateMetadata($categoryTitle, true)); + + if (rand(0, 1)) { + file_put_contents("$categoryPath/index.md", generateMarkdown("Welcome to $categoryTitle", 3)); + } + + $numPosts = $depth === $maxDepth ? rand(5, $postsPerCategory) : rand(5, 15); + for ($p = 0; $p < $numPosts; $p++) { + $title = $titles[array_rand($titles)] . ' ' . $topics[array_rand($topics)]; + $date = date('Y-m-d', strtotime('-' . rand(1, 730) . ' days')); + $slug = generateSlug($title); + $postDir = "$categoryPath/$date-$slug"; + if (!is_dir($postDir)) mkdir($postDir, 0755, true); + file_put_contents("$postDir/index.md", generateMarkdown($title, rand(3, 8))); + if (rand(0, 2) === 0) { + file_put_contents("$postDir/metadata.ini", generateMetadata($title, false)); + } + } + + if ($depth < $maxDepth && rand(0, 1)) { + generateContent($baseDir, $depth + 1, $maxDepth, $postsPerCategory, $parentPath . '/' . $categoryName); + } + } +} + +if (is_dir($baseDir)) exec("rm -rf $baseDir"); +mkdir($baseDir, 0755, true); +file_put_contents("$baseDir/00-intro.md", generateMarkdown("Test Site", 2)); + +echo "Generating test data...\n"; +$start = microtime(true); +generateContent($baseDir, 0, $maxDepth, $postsPerCategory); +$duration = round(microtime(true) - $start, 2); + +$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($baseDir, RecursiveDirectoryIterator::SKIP_DOTS)); +$fileCount = 0; foreach ($iterator as $file) if ($file->isFile()) $fileCount++; +echo "\nGenerated $fileCount files in {$duration}s\n"; +echo "Location: $baseDir\n"; +EOFGEN + + # Run generator + podman exec "$CONTAINER" php /tmp/gen.php "$categories" "$posts" "$depth" + + echo -e "\n${GREEN}Test data created at /tmp/test-content inside container${NC}" + echo -e "${YELLOW}To use: Update app/router.php to use /tmp/test-content${NC}" + echo -e "${YELLOW}Or profile directly: $0 profile /blog-123/${NC}" +} + +cmd_testdata_stats() { + if ! podman exec "$CONTAINER" test -d /tmp/test-content 2>/dev/null; then + echo -e "${RED}No test data found in $CONTAINER:/tmp/test-content${NC}" + echo -e "${YELLOW}Generate with: $0 generate [size]${NC}" + exit 1 + fi + + echo -e "${GREEN}Test Data Statistics ($CONTAINER:/tmp/test-content):${NC}\n" + podman exec "$CONTAINER" bash -c ' + cd /tmp/test-content + echo "Directories: $(find . -type d | wc -l)" + echo "Total files: $(find . -type f | wc -l)" + echo "Markdown files: $(find . -name "*.md" | wc -l)" + echo "Metadata files: $(find . -name "*.ini" | wc -l)" + echo "Total size: $(du -sh . | cut -f1)" + ' +} + +cmd_testdata_clean() { + echo -e "${YELLOW}Removing test data from $CONTAINER:/tmp/test-content...${NC}" + podman exec "$CONTAINER" rm -rf /tmp/test-content 2>/dev/null || true + echo -e "${GREEN}Done!${NC}" +} + +# Main command dispatcher +case "${1:-help}" in + profile) cmd_profile "$2" ;; + analyze) cmd_analyze "$2" ;; + list) cmd_list ;; + clean) cmd_clean ;; + generate|gen) shift; cmd_generate "$@" ;; + testdata-stats|stats) cmd_testdata_stats ;; + testdata-clean) cmd_testdata_clean ;; + help|--help|-h) show_help ;; + *) echo -e "${RED}Unknown command: $1${NC}\n"; show_help; exit 1 ;; +esac