資料庫 INSERT 測試產生了一堆 Log 記錄檔,每一個檔 50 萬筆,每筆格式為序號: 耗時ms

...
9997242: 0ms
9997243: 29ms
9997244: 0ms
9997245: 1ms
9997246: 0ms
9997247: 0ms
9997248: 1ms
9997249: 0ms
...

我想寫個程式,以每 100 筆為單位統計輸出成平均毫秒數、最大值、標準差、90/95/99 百分位數(意指 99% 都低於多少 ms)。

這對 .NET 來說是小菜一碟,花了點時間研究 百分位數,60 行程式打發,用 Parallel.ForEach 平行處理,兩秒內跑完,名符其實的秒殺:

using System.Collections.Concurrent;

public class LogParser
{
    // 百分位數
    static double percentile(int[] sorted, float percent)
    {
        if (sorted == null || !sorted.Any() || percent < 0 || percent > 100)
            throw new ArgumentException();
        var k = (sorted.Length - 1) * percent / 100.0;
        var i = (int)Math.Floor(k);
        var frac = k - i;
        return sorted[i] + frac * (sorted[Math.Min(i + 1, sorted.Length - 1)] - sorted[i]);
    }

    public static void Parse()
    {
        var filesNames = Directory.GetFiles("data", "Test-*.txt");
        var data = new ConcurrentDictionary<int, (int mean, int max, int stdDev, int p90, int p95, int p99)>();
        Parallel.ForEach(filesNames, fileName =>
        {
            var pool = new ConcurrentDictionary<int, List<int>>();
            foreach (var line in File.ReadAllLines(fileName))
            {
                var parts = line.Split(':');
                var idx = (int.Parse(parts[0]) - 1) / 100;
                var dura = int.Parse(parts[1].Replace("ms", string.Empty));
                if (!pool.ContainsKey(idx))
                {
                    pool.TryAdd(idx, new List<int>());
                }
                pool[idx].Add(dura);
            }
            foreach (var kvp in pool)
            {
                var sorted = kvp.Value.OrderBy(x => x).ToArray();
                var count = sorted.Length;
                var mean = (int)sorted.Average();
                var max = sorted.Max();
                // 標準差
                var stdDev = (int)Math.Sqrt(sorted.Sum(x => Math.Pow(x - mean, 2)) / count);
                var p90 = (int)percentile(sorted, 90);
                var p95 = (int)percentile(sorted, 95);
                var p99 = (int)percentile(sorted, 99);
                data.TryAdd(kvp.Key, (mean, max, stdDev, p90, p95, p99));
            }
        });
        var ordered = data.OrderBy(kvp => kvp.Key);
        using var sw = new StreamWriter("results\\stats-cs.csv");
        sw.WriteLine("Index,Mean,Max,StdDev,P90,P95,P99");
        foreach (var kvp in ordered)
        {
            var v = kvp.Value;
            sw.WriteLine($"{kvp.Key},{v.mean},{v.max},{v.stdDev},{v.p90},{v.p95},{v.p99}");
        }
    }
}

結果如下:

過往,接下來我應該會開 Excel 匯 CSV 做圖表。今年我打算把 Python 也納入技能樹,決定練習改用 Python 來完成。

問我想學 Python 的理由?

很簡單,因為

Python 是 ChatGPT 跟 Github Copilot 的母語

可預見未來幾年 AI 將主導資訊系統發展方向,加上在學術研究領域的廣泛應用,簡單說,會 Python 跟懂英文的好處異曲同工,剩下的不需要多解釋了吧。

手上已有 C# 版,雖然跟 Python 不熟,但在 Github Copliot 的指導下,我很快寫出一個可執行版本:

import glob
import concurrent.futures
import statistics

def parse_file(file):
    with open(file, 'r') as f:
        # 資料格式:12345: 0ms
        lines = f.readlines()
    stats = {}
    for line in lines:
        p = line.split(':')
        seq = int(p[0]) - 1
        # 註:用 // 除會取整數(無條件捨去)、用 / 除為浮點數
        grpSeq = seq//100
        dura = int(p[1].strip('ms\n'))
        # setdefault 可在 Dictionary 無值時建空陣列
        stats.setdefault(grpSeq, []).append((seq, dura))
    # 計算每組之平均、最大、標準差,90%、95%, 99% 百分位數
    data = []
    for k, v in stats.items():
        duras = [x[1] for x in v]
        duras.sort()
        count = len(v)
        mean = int(statistics.mean(duras))
        maxVal = max(duras)
        std = int(statistics.pstdev(duras, mu=mean))
        p90 = duras[int(count * 0.9) - 1]
        p95 = duras[int(count * 0.95) - 1]
        p99 = duras[int(count * 0.99) - 1]
        data.append((k, mean, maxVal, std, p90, p95, p99))
    return data

files = glob.glob('.\data\Test-*.txt')
with concurrent.futures.ThreadPoolExecutor() as executor:
    results = executor.map(parse_file, files)
# 將結果組合成一個 list
data = []
for r in results:
    data += r
# 依組別排序
data.sort(key=lambda x: x[0])
# 輸出成 csv 檔
with open('results\stats-py.csv', 'w') as f:
    f.write('Index,Mean,Max,StdDev,P90,P95,P99\n')
    for d in data:
        f.write(f'{d[0]},{d[1]},{d[2]},{d[3]},{d[4]},{d[5]},{d[6]}\n')

用類似的運算原理,換個語言寫,可得到跟 C# 完全相同的結果:

但有個問題,以上的程式跑完要 16 秒,是 C# 的八倍! 不過,這是因為我並沒有用 Python 最擅長的方式處理數學運算,豐富且強大的數學程式庫是 Python 的強項,別什麼都自己來。但應該怎麼改?你為什麼不問問神奇海螺 Copilot 呢?

將程式碼丟給 Copilot 請它給建議,Copilot 建議我用 Pandas (排序、分群、統計用程式庫) 跟 NumPy (矩陣與數學運算百寶箱) 簡化程式,pandas 甚至有函式可以直接讀取 CSV,有現成的函式可以分群,計算百分位數,短短幾行做完。

程式只有 Pool() 跑多執行緒的部分有點問題,我改成用 ThreadPoolExecutor(),精簡版就順利完成了。

import glob
import pandas as pd
import numpy as np

def parse_file(file):
    df = pd.read_csv(file, sep=":", names=["seq", "dura"])
    df["dura"] = df["dura"].str.strip("ms\n").astype(int)
    df["Index"] = (df["seq"] - 1) // 100
    grouped = df.groupby("Index")["dura"]
    data = pd.concat(
        [
            grouped.mean().astype(int),
            grouped.max(),
            grouped.std().astype(int),
            grouped.quantile(0.9).astype(int),
            grouped.quantile(0.95).astype(int),
            grouped.quantile(0.99).astype(int),
        ],
        axis=1,
    )
    data.columns = ["Mean", "Max", "StdDev", "P90", "P95", "P99"]
    return data.reset_index()

files = glob.glob(".\data\Test-*.txt")
with concurrent.futures.ThreadPoolExecutor() as executor:
    results = executor.map(parse_file, files)

data = pd.concat(results).sort_values("Index")
data.to_csv("results\stats-py2.csv", index=False)

Copilot 提供的版本 3 秒跑完,跟 C# 不相上下,除了標準差有點不同,其餘數字一致。

算出結果,下一步靠 Python 豐富的程式庫把結果畫成圖表:(繼續請 Copilot 手把手帶著我寫完)

import matplotlib.pyplot as plt

def plot_stats(filename, title):
    with open(filename, "r") as f:
        stats = f.readlines()
    # 略過第一行(Header)
    stats = stats[1:]
    stats = [list(map(int, row.strip().split(","))) for row in stats]
    x = [row[0] for row in stats] # Index
    yMean = [row[1] for row in stats] # Mean
    yMax = [row[2] for row in stats] # Max
    yP99 = [row[6] for row in stats] # P99
    # 設定 12 吋寬、4 吋高
    fig, ax1 = plt.subplots(figsize=(12, 4))
    ax1.plot(x, yMean, alpha=0.8, color="b")
    # 偵測 y 軸的最大值,設定 y 軸刻度
    maxY = max(yMean)
    if maxY < 100:
        tick = 10
    elif maxY < 1000:
        tick = 100
    ax1.set_yticks(range(0, int(maxY * 2), tick))
    ax1.set_ylabel("Avg(ms)", color="b")
    ax1.get_xaxis().set_major_formatter(
        plt.FuncFormatter(lambda x, loc: "{:,}".format(int(x / 10)))
    )
    ax1.set_xlabel("rows (k)")

    # 最大值
    ax2 = ax1.twinx() # 與 ax1 共用 x 軸
    ax2.scatter(x, yMax, alpha=0.6, color="r", s=1)
    ax2.set_ylabel("Max/Percentile(ms)")
    
    # 加上 99 百分位數
    ax3 = ax1.twinx()   
    ax3.set_ylim(ax2.get_ylim()) # 與 ax2 Y 軸刻度一致
    ax3.scatter(x, yP99, alpha=0.6, color="g", s=1.5)

    plt.title(title)

    plt.show()

plot_stats("results\stats-cs.csv", "Execution Time")

就醬,在 Github Copliot 教練的細心指導下,我完成第一支解析測試結果繪成圖表的 Python 程式,技能老樹冒出新芽。未來遇到用 Python 更省時省力的場合,我就有更多選擇囉~

An example of using Python to analyze test logs and calculate mean, max, percentile and draw statistics chart.


Comments

# by Bill

之前把資料手動用程式的方式樞紐太花時間, 結果把同樣資料丟到Sqlite(memory)後 , 下完SQL直接收工 不曉得黑大有模沒有興趣再測一個sql 耗時版本

# by Jeffrey

to Bill,過去做樞紐我習慣轉 CSV 後交給 Excel 做,好處是後續審閱者可用 GUI 自己去調分析維度。寫成程式主要用於會反覆執行測試多次的情境,Python pandas 有樞紐分析功能,串 matplotlib 還可直出圖表,感覺比丟 Sqlite 又更省力一些。

# by Bill

的確提交給user,csv真的是不二選擇...前陣子資料搭pivot.js給user直接於瀏覽器使用反應還不錯,但是資料不能太大(風扇直接起飛)

# by yoyo

python可以玩玩看streamlit 做資料視覺化

Post a comment