Aktivieren Sie die Leistung der GPU von Null auf Eins: mithilfe der TensorRT-Quantisierung und der parallelen CUDA-Programmierung

Anmerkungen zur TensorRT-Studie

Vorläufige Zusammenfassung: TensorRT-Modelloptimierung und Argumentation: Von Null auf Eins, aktivieren Sie die Leistung der GPU: Verwenden Sie TensorRT, um Deep-Learning-Modelle zu optimieren und auszuführen, Ihr TensorRT-Einführungsleitfaden. In
diesem Artikel wird die Modellquantisierung unter TensorRT und die CUDA-Parallel-Computing-Programmierung vorgestellt.

Quantisierung des TensorRT-Modells

Die Modellquantisierung ist eine Technik zur Deep-Learning-Modelloptimierung, dem Prozess der Konvertierung von Parametern (wie Gewichtungen und Bias) in einem Deep-Learning-Modell von Gleitkommazahlen in ganze Zahlen oder Festkommazahlen. Sein Hauptziel besteht darin, die Parameter im Modell von 32-Bit-Gleitkomma (FP32) auf ein Format mit geringerer Genauigkeit zu reduzieren, wie z. B. 8-Bit-Ganzzahl (INT8) oder 16-Bit-Gleitkomma (FP16). Dadurch können die Speicher- und Berechnungskosten des Modells gesenkt und so der Zweck der Modellkomprimierung und Betriebsbeschleunigung erreicht werden. Lassen Sie beispielsweise bei der int8-Quantisierung die im ursprünglichen Modell in 32 Bit gespeicherte Zahl auf 8 Bit abbilden und dann berechnen (der Bereich beträgt [-128,127]).

Der Modellquantisierungsvorgang kann drei Hauptvorteile bringen:

  1. Reduzierte Modellgröße : Durch die Verwendung weniger Bits zur Darstellung der Gewichte eines Modells kann die Größe des Modells erheblich reduziert werden, sodass das Modell weniger Speicher und Speicherplatz beansprucht. Beispielsweise kann die Konvertierung eines Modells von FP32 auf INT8 die Größe des Modells um den Faktor vier reduzieren.
  2. Beschleunigen Sie die Inferenz : Kleinere Modelle bedeuten, dass bei der Vorwärtsausbreitung (d. h. beim Treffen von Vorhersagen) weniger Daten verarbeitet werden müssen. Darüber hinaus bieten einige Hardwarekomponenten wie GPUs und dedizierte KI-Beschleuniger spezielle Hardwareunterstützung für die Verarbeitung mehrerer Werte mit geringer Genauigkeit in einem einzigen Vorgang. Beide Faktoren können die Inferenzgeschwindigkeit des Modells erheblich erhöhen. Durch den einmaligen Zugriff auf 32-Bit-Gleitkommadaten kann viermal auf Int8-Ganzzahldaten zugegriffen werden.
  3. Reduzierter Ressourcenverbrauch : Kleinere Modelle erhöhen nicht nur die Verarbeitungsgeschwindigkeit, sondern reduzieren auch den erforderlichen Energieverbrauch. Dies ist besonders wichtig für die Ausführung von Modellen in ressourcenbeschränkten Umgebungen wie mobilen Geräten und eingebetteten Systemen.

Dieser Vorteil geht auch mit Einbußen bei der Modellgenauigkeit einher, da während des Quantisierungsprozesses möglicherweise einige Genauigkeitsinformationen verloren gehen. In vielen Fällen ist dieser Genauigkeitsverlust akzeptabel, da er nur geringe Auswirkungen auf die Gesamtleistung des Modells hat. Bei einigen Anwendungen, die eine hohe Präzision erfordern, kann die Quantisierung jedoch zu Leistungseinbußen führen. Daher ist die Quantisierungskalibrierung ein wichtiger Schritt . Das Ziel besteht darin, eine ideale Zuordnung zu finden, um 32-Bit-Gleitkommawerte in Werte mit niedriger Genauigkeit (z. B. INT8) umzuwandeln und gleichzeitig die Genauigkeit des Modells zu erhalten möglich.

Einführung in Modellquantisierungsalgorithmen

Gängige Modellquantisierungsalgorithmen sind:

  1. Entropiekalibrierung : Die Entropiekalibrierung ist ein dynamischer Kalibrierungsalgorithmus. Die Hauptidee der Entropiekalibrierungsmethode besteht darin, einen optimalen Quantisierungsschwellenwert zu finden, um die Kullback-Leibler (KL)-Divergenz zwischen der quantisierten Datenverteilung (ausgedrückt in INT8) und der ursprünglichen Datenverteilung (ausgedrückt in FP32) zu minimieren. In der Statistik ist die KL-Divergenz ein Maß für die Differenz zwischen zwei Wahrscheinlichkeitsverteilungen. Daher besteht das Ziel dieser Methode darin, die quantisierte Datenverteilung so nah wie möglich an die ursprüngliche Datenverteilung heranzuführen.
  2. Min-Max-Kalibrierung : Diese Methode ist relativ intuitiver und einfacher. Weil die Minimal- und Maximalwerte im Kalibrierungsdatensatz direkt als Quantisierungsschwellenwerte verwendet werden. Mit anderen Worten: Der Minimalwert wird auf den Minimalwert von INT8 (z. B. -128) und der Maximalwert auf den Maximalwert von INT8 (z. B. 127) abgebildet. Dieser Ansatz hat den Vorteil, dass er schnell zu berechnen ist, erzielt jedoch möglicherweise nicht in allen Fällen die beste Leistung.
  3. Parametrische Kalibrierung : Diese Methode optimiert die Parameter des Modells während des Quantisierungsprozesses, und die parametrische Kalibrierung wird während des Quantisierungsprozesses optimiert, um die quantisierte Modellausgabe so nah wie möglich an der ursprünglichen Modellausgabe zu halten. Bei der parametrischen Kalibrierung optimieren wir die Parameter jeder Schicht, um Quantisierungsfehler zu minimieren. Da jedoch jeder Parameter optimiert werden muss, kann der Quantisierungsfehler genauer gesteuert werden, was zu einem relativ großen Rechenaufwand führt. In Situationen, in denen es auf Präzision ankommt, kann die parametrische Kalibrierung die beste Wahl sein. In einigen Anwendungen kann diese Methode bessere Ergebnisse liefern als die Entropiekalibrierung oder die Min-Max-Kalibrierung.
  4. Perzentilkalibrierung : Die Perzentilkalibrierung ist eine Kalibrierungsmethode für Daten mit Rauschen oder Ausreißern. Diese Methode berechnet zunächst die Verteilung aller Daten und findet dann einen Schwellenwert, sodass Daten über diesem Schwellenwert nur einen kleinen Prozentsatz aller Daten ausmachen (z. B. 1 % oder 0,1 %). Diese Daten, die als Rauschen oder Ausreißer gelten, werden dann ignoriert, während andere Daten zur Berechnung der Quantisierungsparameter verwendet werden. Der Vorteil der prozentualen Kürzungskalibrierung besteht darin, dass sie beim Umgang mit verrauschten oder ausreißerhaften Daten eine hohe Genauigkeit beibehält. Der Nachteil besteht jedoch darin, dass einige wichtige Informationen verloren gehen können, insbesondere wenn diese Daten, die als Rauschen oder Ausreißer gelten, tatsächlich einen erheblichen Einfluss auf die Vorhersageergebnisse des Modells haben. Die prozentuale Kürzungskalibrierung eignet sich für Szenarien, in denen die Daten viel Rauschen oder Ausreißer enthalten.

In diesem Fall werden wir hauptsächlich die C++-Codierung der Entropiekalibrierung und der Minimum-Maximum-Kalibrierung unter TensorRT vorstellen. Beide Kalibrierungsmethoden müssen einige Daten für die Durchführung von Inferenzen während der Kalibrierung vorbereiten, um die Datenverteilung zu statistiken. Diese Daten, sogenannte Kalibrierungsdatensätze, sind ein wesentlicher Bestandteil des Quantifizierungsprozesses. Mit diesem Datensatz kann TensorRT die Datenverteilung verstehen und anhand dieser Daten entscheiden, wie Gewichte und Aktivierungen am besten von Gleitkommazahlen auf Darstellungen mit geringer Genauigkeit reduziert werden können. Um die Ladegeschwindigkeit des Modells zu optimieren, kann TensorRT die Kalibrierungstabelle als .cache-Datei speichern. Wenn wir die INT8-Quantisierung in TensorRT durchführen, wird der Kalibrierungsdatensatz während des ersten Laufs zur Kalibrierung verwendet, um eine Kalibrierungstabelle zu erstellen. Diese Kalibrierungstabelle wird zum späteren Laden und Ableiten des Modells in einer .cache-Datei gespeichert.

Das Entwerfen einer Kalibrierungs-Cache-Tabelle hat zwei Hauptvorteile:

  1. **Beschleunigtes Laden des Modells:** Beim ersten Durchlauf kann der Kalibrierungsvorgang einige Zeit dauern. Sobald jedoch die Kalibrierungstabelle generiert und als .cache-Datei gespeichert ist, können wir diese .cache-Datei direkt laden, wenn das nachfolgende Modell geladen wird, ohne dass eine erneute Kalibrierung erforderlich ist. Dies kann das Laden des Modells erheblich beschleunigen.
  2. **Konsistenz sicherstellen:** Nach dem Speichern der Kalibrierungstabelle werden bei jedem Laden des Modells dieselben Quantisierungsparameter verwendet, was die Konsistenz der Inferenz gewährleistet.

Es ist zu beachten, dass diese .cache-Datei eng mit dem ursprünglichen Modell- und Kalibrierungsdatensatz zusammenhängt. Das heißt, dieselbe .cache-Datei kann nur verwendet werden, wenn sich weder das Modell noch der Kalibrierungsdatensatz geändert haben. Wenn sich das Modell oder der Kalibrierungsdatensatz ändert, muss er neu kalibriert und eine neue .cache-Datei generiert werden.

Bei der Vorbereitung des Kalibrierungsdatensatzes müssen die allgemeinen Daten repräsentativ sein, d. h. die Daten müssen mit der endgültigen tatsächlichen Landeszene übereinstimmen. **Wenn der Kalibrierungsdatensatz nicht repräsentativ für die endgültigen realen Anwendungsdaten ist, kann der Quantisierungsprozess zu einem Verlust an Genauigkeit des Modells führen. In praktischen Anwendungen werden im Allgemeinen 500–1000 Daten zur Quantifizierung vorbereitet (die spezifische Anzahl muss möglicherweise entsprechend Ihrem Modell und Ihrer Anwendung angepasst werden). Wenn Ihr Modell beispielsweise zum Verarbeiten von Bildern verwendet wird, muss der Kalibrierungsdatensatz eine große Vielfalt an Bildern enthalten, einschließlich verschiedener Szenen, Lichtverhältnisse, Zielobjekte usw.

TensorRT erreicht maximale und minimale Kalibrierung

In TensorRT kann die Entropiekalibrierung oder Min-Max-Kalibrierung durch die Implementierung IInt8EntropyCalibrator2einer Schnittstelle oder Schnittstelle durchgeführt werden, und es müssen mehrere virtuelle Funktionsmethoden implementiert werden:IInt8MinMaxCalibrator

  • getBatch()Methode: Wird zur Bereitstellung einer Reihe von Kalibrierungsdaten verwendet.
  • readCalibrationCache()und writeCalibrationCache()Methode: Implementieren Sie einen Caching-Mechanismus, um zu vermeiden, dass Kalibrierungsdaten bei jedem Start neu geladen werden.

build.cuDie Schnittstelle ist im Code für IInt8MinMaxCalibratordie statische Offline-Kalibrierung des INT8-Modells implementiert (Sie können sie IInt8EntropyCalibrator2zum Vergleich der Ergebnisse durch eine Entropiekalibrierung ersetzen).

// 定义校准数据读取器
// 如果要用entropy的话改为:IInt8EntropyCalibrator2
class CalibrationDataReader : public IInt8MinMaxCalibrator
{
    
    
  ....
}
  • Zu den Parametern, die der Konstruktor übergeben muss, gehören Datenverzeichnis, Datenliste und BatchSize . Das Datenverzeichnis ist der Ordnerpfad, in dem die Kalibrierungsdaten gespeichert sind, und die Datenliste ist eine Liste mit den Namen der Kalibrierungsdatendateien. Der Zweck der Übergabe dieser Parameter besteht darin, dem Kalibrator mitzuteilen, wo sich die Kalibrierungsdaten befinden und welche Chargengröße die Daten verarbeiten sollen. Im Konstruktor werden auch die Dimension und Größe des Eingabetensors entsprechend den Anforderungen des Modells initialisiert und der entsprechende Speicher auf dem Gerät zugewiesen . Dies liegt daran, dass diese Informationen für TensorRT erforderlich sind, um Inferenzberechnungen durchzuführen.
CalibrationDataReader(const std::string& dataDir, const std::string& list, int batchSize = 1)
	: mDataDir(dataDir), mCacheFileName("weights/calibration.cache"), mBatchSize(batchSize), mImgSize(kInputH* kInputW)
{
    
    
	// 设置网络输入的尺寸
	mInputDims = {
    
     1, 3, kInputH, kInputW };
	// 计算输入的元素总数
	mInputCount = mBatchSize * samplesCommon::volume(mInputDims);
	// 初始化CUDA预处理环境,为图像大小分配空间
	cuda_preprocess_init(mImgSize);
	// 在设备上为批处理数据分配空间
	cudaMalloc(&mDeviceBatchData, kInputH * kInputW * 3 * sizeof(float));
	// 加载校准数据集文件列表
	std::ifstream infile(list);
	std::string line;
	while (std::getline(infile, line))
	{
    
    
		sample::gLogInfo << line << std::endl;
		mFileNames.push_back(line);
	}
	// 计算总批次数量
	mBatchCount = mFileNames.size() / mBatchSize;
	std::cout << "CalibrationDataReader: " << mFileNames.size() << " images, " << mBatchCount << " batches." << std::endl;
}
  • getBatch()Die Aufgabe der Methode besteht darin, eine Datenmenge für den Kalibrierungsprozess bereitzustellen. Diese Methode erfordert das Lesen des aktuellen Stapels von Kalibrierungsdaten von der Festplatte in den CPU-Speicher und das anschließende Kopieren in den GPU-Gerätespeicher. Dieser Prozess entspricht dem Vorwärtsausbreitungsprozess des Deep-Learning-Modells, d. h. ausgehend von der Eingabeschicht, durchläuft nacheinander jede verborgene Schicht und erreicht schließlich die Ausgabeschicht.
bool getBatch(void* bindings[], const char* names[], int nbBindings) noexcept override
{
    
    
	// 检查是否还有更多批次的数据
	if (mCurBatch + 1 > mBatchCount)
	{
    
    
		return false;
	}

	// 每个图像的偏移量
	int offset = kInputW * kInputH * 3 * sizeof(float);
	for (int i = 0; i < mBatchSize; i++)
	{
    
    
		int idx = mCurBatch * mBatchSize + i;
		std::string fileName = mDataDir + "/" + mFileNames[idx];
		cv::Mat img = cv::imread(fileName);
		int new_img_size = img.cols * img.rows;
		if (new_img_size > mImgSize)
		{
    
    
			mImgSize = new_img_size;
			cuda_preprocess_destroy();      // 如果新图像的大小超过之前的内存空间,释放之前的内存
			cuda_preprocess_init(mImgSize); // 并重新分配适应新图像的内存
		}
		// 使用GPU处理输入图像,并把结果写入到设备内存
		process_input_gpu(img, mDeviceBatchData + i * offset);
	}
	for (int i = 0; i < nbBindings; i++)
	{
    
    
		if (!strcmp(names[i], kInputTensorName))
		{
    
    
			// 把设备内存的地址绑定到输入张量
			bindings[i] = mDeviceBatchData + i * offset;
		}
	}

	// 更新当前批次索引
	mCurBatch++;
	return true;
}
  • readCalibrationCache()Das Ziel der Methode besteht darin, den Kalibrierungscache aus der Cachedatei auszulesen. Diese Methode gibt einen Zeiger auf die zwischengespeicherten Daten und die Größe der zwischengespeicherten Daten zurück. Wird zurückgegeben, wenn keine Daten zwischengespeichert sind nullptr. Kalibrierungs-Caching ist hier ein wichtiges Konzept. Um die Geschwindigkeit der Modellinferenz zu verbessern, speichern wir normalerweise die Ergebnisse des Kalibrierungsprozesses, sodass bei der nächsten Inferenz keine erneute Kalibrierung erforderlich ist, sondern der gespeicherte Kalibrierungscache direkt gelesen wird, wodurch die Effizienz der Inferenz verbessert wird .
const void* readCalibrationCache(std::size_t& length) noexcept override 
{
    
    
	// 清空校准缓存
	mCalibrationCache.clear();

	// 以二进制形式打开缓存文件
	std::ifstream input(mCacheFileName, std::ios::binary);
	input >> std::noskipws;

	// 如果文件状态良好,即文件可读且没有其他错误
	if (input.good())
	{
    
    
		// 从输入流中拷贝数据到校准缓存
		std::copy(std::istream_iterator<char>(input), std::istream_iterator<char>(),
			std::back_inserter(mCalibrationCache));
	}

	// 获取缓存数据的大小
	length = mCalibrationCache.size();

	// 如果有缓存数据,则返回指向缓存数据的指针;否则返回 nullptr
	return length ? mCalibrationCache.data() : nullptr;
}
  • writeCalibrationCache()Die Methode besteht darin, den Kalibrierungscache in die Cachedatei zu schreiben. Es ist notwendig, den Cache-Datenzeiger und die Größe der Cache-Daten an den Dateiausgabestream zu übergeben und diese in die Cache-Datei zu schreiben. Bei diesem Vorgang wird das Ergebnis des Kalibrierungsprozesses tatsächlich gespeichert, sodass es beim nächsten Mal direkt gelesen und verwendet werden kann.
// writeCalibrationCache() 将校准缓存写入到缓存文件中
// 在该方法中,需要将缓存数据指针和缓存数据的大小传递给文件输出流,并将其写入到缓存文件中
void writeCalibrationCache(const void* cache, std::size_t length) noexcept override 
{
    
    
	// 将校准缓存写入到文件中
	std::ofstream output(mCacheFileName, std::ios::binary);
	output.write(reinterpret_cast<const char*>(cache), length);
}

Im spezifischen Geschäftscode wird zunächst geprüft, ob die aktuelle Plattform die INT8-Argumentation unterstützt. Wenn dies nicht unterstützt wird, wird eine Warnmeldung ausgegeben und die Inferenzgenauigkeit des Modells wird auf „ FP16Modus“ gesetzt. Dadurch soll sichergestellt werden, dass das Modell weiterhin Inferenzen auf Plattformen durchführen kann, die INT8 nicht unterstützen. CalibrationDataReaderAndernfalls wird ein Objekt vom Typ erstellt calibratorund auf den INT8-Kalibrator eingestellt. Setzen Sie dann das INT8-Modus-Flag im Konfigurationsobjekt config.

// 检查当前平台是否支持 INT8 推理
if (!builder->platformHasFastInt8())
{
    
    
	// 如果不支持 INT8 推理,则打印警告信息并将引擎设置为 FP16 模式
	sample::gLogInfo << "设备不支持int8." << std::endl;
	config->setFlag(nvinfer1::BuilderFlag::kFP16);
}
else
{
    
    
	// 如果支持 INT8 推理,创建一个 CalibrationDataReader 对象,并将其设置为 INT8 校准器
	auto calibrator = new CalibrationDataReader(calib_dir, calib_list_file);
	// 为配置对象设置 INT8 模式标志
	config->setFlag(nvinfer1::BuilderFlag::kINT8);
	// 设置 INT8 校准器
	config->setInt8Calibrator(calibrator);
}

Vollständiger Code

#include "NvInfer.h"
#include "NvOnnxParser.h" 
#include "logger.h"
#include "common.h"
#include "buffers.h"
#include "cassert"
#include "utils/config.h"
#include "utils/preprocess.h"
#include "utils/types.h"

// 定义校准数据读取器, 最大最小值校准
// 如果要用熵校准entropy的话改为:IInt8EntropyCalibrator2
class CalibrationDataReader : public nvinfer1::IInt8MinMaxCalibrator
{
    
    
private:
    std::string mDataDir;
    std::string mCacheFileName;
    std::vector<std::string> mFileNames;
    int mBatchSize;
    nvinfer1::Dims mInputDims;
    int mInputCount;
    float *mDeviceBatchData {
    
     nullptr };
    int mBatchCount;
    int mImgSize;
    int mCurBatch{
    
    0};
    std::vector<char> mCalibrationCache;

private:
    void load_dataClassFile(const std::string& filepath)
    {
    
    
        std::ifstream ifile(filepath);
        std::string Line;
        while (std::getline(ifile, Line))
        {
    
    
            sample::gLogInfo << Line << std::endl;
            mFileNames.push_back(Line);
        }
        mBatchCount = mFileNames.size() / mBatchSize;
        std::cout << "CalibrationDataReader: " << mFileNames.size() 
                  << " images, " << mBatchCount << " batches." << std::endl;
    }

public:
    // 构造函数需要传递的参数包括数据目录、数据列表、BatchSize。
    // 通常会根据模型的需求,初始化输入张量的维度和大小,并在设备上分配相应的内存。
    CalibrationDataReader(const std::string& dataDir, const std::string& filepath, int batchSize = 1)
        : mDataDir(dataDir), mCacheFileName("weights/calibration.cache"),
          mBatchSize(batchSize), mImgSize(kInputH * kInputW)
    {
    
    
        mInputDims = {
    
    1, 3, kInputH, kInputW};
        mInputCount = mBatchSize * samplesCommon::volume(mInputDims);
        cuda_preprocess_init(mImgSize);
        cudaMalloc(&mDeviceBatchData, kInputH * kInputW * 3 * sizeof(float));
        load_dataClassFile(filepath);
    }

    int32_t getBatchSize() const noexcept override
    {
    
    
        return mBatchSize;
    }

    bool getBatch(void* bindings[], const char *names[], int nbBindings) noexcept override
    {
    
    
        if (mCurBatch + 1 > mBatchCount)
        {
    
    
            return false;
        }
        int offset = kInputW * kInputH * 3 * sizeof(float);
        for (int i = 0; i < mBatchSize; i++)
        {
    
    
            int idx = mCurBatch * mBatchSize + i;
            std::string filename = mDataDir + "/" + mFileNames[idx];
            cv::Mat image = cv::imread(filename);
            int new_img_size = image.cols * image.rows;
            if (new_img_size > mImgSize)
            {
    
    
                mImgSize = new_img_size;
                cuda_preprocess_destroy();
                cuda_preprocess_init(mImgSize);
            }
            process_input_gpu(image, mDeviceBatchData + i * offset);
        }
        for (int i = 0; i < nbBindings; i++)
        {
    
    
            if (!strcmp(names[i], kInputTensorName))
            {
    
    
                bindings[i] = mDeviceBatchData + i * offset;
            }
        }
        mCurBatch++;
        return true;
    }

    const void* readCalibrationCache(std::size_t& length) noexcept override
    {
    
    
        mCalibrationCache.clear();

        std::ifstream input(mCacheFileName, std::ios::binary);
        input >> std::noskipws;
        if (input.good())
        {
    
    
            std::copy(std::istream_iterator<char>(input), std::istream_iterator<char>(),
                      std::back_inserter(mCalibrationCache));
        }
        length = mCalibrationCache.size();
        return length ? mCalibrationCache.data() : nullptr;
    }

    void writeCalibrationCache(const void *cache, std::size_t length) noexcept override
    {
    
    
        std::ofstream output(mCacheFileName, std::ios::binary);
        output.write(reinterpret_cast<const char*>(cache), length);
    }
};


int main(int argc, char** argv)
{
    
    
    if (argc != 4)
    {
    
    
        std::cerr << "请输入onnx文件位置: ./build/[onnx_file] [calib_dir] [calib_list_file]" << std::endl;
        return -1;
    }
    // 命令行获取onnx文件路径、校准数据集路径、校准数据集列表文件
    char* onnx_file = argv[1];
    char* calib_dir = argv[2];
    char* calib_list_file = argv[3];
    // ========== 1. 创建builder:创建优化的执行引擎(ICudaEngine)的关键工具 ==========
    // 在几乎所有使用TensorRT的场合都会使用到IBuilder
    // 只要TensorRT来进行优化和部署,都需要先创建和使用IBuilder。
    std::unique_ptr<nvinfer1::IBuilder> builder = std::unique_ptr<nvinfer1::IBuilder>(nvinfer1::createInferBuilder(sample::gLogger.getTRTLogger()));
    if (!builder)
    {
    
    
        std::cerr << "Failed to create build" << std::endl;
        return -1;
    } 
    std::cout << "Successfully to create builder!!" << std::endl;

    // ========== 2. 创建network:builder--->network ==========
    // 设置batch, 数据输入的批次量大小
    // 显性设置batch
    const unsigned int explicitBatch = 1U << static_cast<uint32_t>(nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH);
    std::unique_ptr<nvinfer1::INetworkDefinition> network = std::unique_ptr<nvinfer1::INetworkDefinition>(builder->createNetworkV2(explicitBatch));
    if (!network)
    {
    
    
        std::cout << "Failed to create network" << std::endl;
        return -1;
    }

    // 创建onnxparser,用于解析onnx文件
    std::unique_ptr<nvonnxparser::IParser> parser = std::unique_ptr<nvonnxparser::IParser>(nvonnxparser::createParser(*network, sample::gLogger.getTRTLogger()));
    // 调用onnxparser的parseFromFile方法解析onnx文件
    bool parsed = parser->parseFromFile(onnx_file, static_cast<int>(sample::gLogger.getReportableSeverity()));
    if (!parsed)
    {
    
    
        std::cerr << "Failed to parse onnx file!!" << std::endl;
        return -1;
    }
    // 配置网络参数
    // 我们需要告诉tensorrt我们最终运行时,输入图像的范围,batch size的范围。这样tensorrt才能对应为我们进行模型构建与优化。
    nvinfer1::ITensor* input = network->getInput(0); // 获取了网络的第一个输入节点。
    nvinfer1::IOptimizationProfile* profile = builder->createOptimizationProfile(); // 创建了一个优化配置文件。
    // 网络的输入节点就是模型的输入层,它接收模型的输入数据。
    // 在 TensorRT 中,优化配置文件(Optimization Profile)用于描述模型的输入尺寸和动态尺寸范围。
    // 通过优化配置文件,可以告诉 TensorRT 输入数据的可能尺寸范围,使其可以创建一个适应各种输入尺寸的优化后的模型。

    // 设置最小尺寸
    profile->setDimensions(input->getName(), nvinfer1::OptProfileSelector::kMIN, nvinfer1::Dims4(1, 3, 640, 640));
    // 设置最优尺寸
    profile->setDimensions(input->getName(), nvinfer1::OptProfileSelector::kOPT, nvinfer1::Dims4(1, 3, 640, 640));
    // 设置最大尺寸
    profile->setDimensions(input->getName(), nvinfer1::OptProfileSelector::kMAX, nvinfer1::Dims4(1, 3, 640, 640));

    // ========== 3. 创建config配置:builder--->config ==========
    // 配置解析器
    std::unique_ptr<nvinfer1::IBuilderConfig> config = std::unique_ptr<nvinfer1::IBuilderConfig>(builder->createBuilderConfig());
    if (!config)
    {
    
    
        std::cout << "Failed to create config" << std::endl;
        return -1;
    }
    // 添加之前创建的优化配置文件(profile)到配置对象(config)中
    // 优化配置文件(profile)包含了输入节点尺寸的设置,这些设置会在模型优化时被使用。
    config->addOptimizationProfile(profile);
    // 设置精度
    if (!builder->platformHasFastInt8())
    {
    
    
        sample::gLogInfo << "设备不支持int8,本次将默认使用int16" << std::endl;
        config->setFlag(nvinfer1::BuilderFlag::kFP16);
    }
    else {
    
    
        sample::gLogInfo << "设备支持int8,本次将使用int8量化" << std::endl;
        auto calibrator = new CalibrationDataReader(calib_dir, calib_list_file);
        config->setFlag(nvinfer1::BuilderFlag::kINT8);
        config->setInt8Calibrator(calibrator);
    }

    // config->setFlag(nvinfer1::BuilderFlag::kFP16);
    builder->setMaxBatchSize(1);
    config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 1 << 30);

    // 创建流,用于设置profile
    auto profileStream = samplesCommon::makeCudaStream();
    if (!profileStream)
    {
    
    
        std::cerr << "Failed to create CUDA profileStream File" << std::endl;
        return -1;
    }
    config->setProfileStream(*profileStream);

    // ========== 5. 序列化保存engine ==========
    // 使用之前创建并配置的 builder、network 和 config 对象来构建并序列化一个优化过的模型。
    std::unique_ptr<nvinfer1::IHostMemory> plan = std::unique_ptr<nvinfer1::IHostMemory>(builder->buildSerializedNetwork(*network, *config));
    std::ofstream engine_file("./weights/best.engine", std::ios::binary);
    assert(engine_file.is_open() && "Failed to open engine file");
    engine_file.write((char *)plan->data(), plan->size());
    engine_file.close();

    // ========== 6. 释放资源 ==========
    std::cout << "Engine build success!" << std::endl;
    return 0;
}

CUDA-GPU Parallel Computing: Von der Theorie zur Bildverarbeitungspraxis

Einführung in die CUDA-GPU-Entwicklung

CUDA ist eine von NVIDIA eingeführte Parallel-Computing-Plattform und ein Programmiermodell, das GPUs (Grafikprozessoren) für effizientes Parallel-Computing nutzen kann. Die Programmierung mit CUDA kann die Leistung rechenintensiver Anwendungen wie Bildverarbeitung, wissenschaftliches Rechnen, maschinelles Lernen, Deep Learning und mehr verbessern. Im Vergleich zur Verwendung einer CPU für serielles Rechnen kann die Verwendung einer GPU für paralleles Rechnen die Rechengeschwindigkeit und -effizienz erheblich verbessern. Bei der Bildverarbeitung oder beim Deep Learning verarbeiten wir Bilder normalerweise vor, z. B. durch Größenänderung von Bildern, Normalisierung, Kanalwechsel usw. Bei diesen Vorgängen muss jedes Pixel des Bildes verarbeitet werden. Wenn die CPU also für die serielle Verarbeitung verwendet wird, werden viele Rechenressourcen und Zeit verbraucht. Daher verwenden wir normalerweise CUDA für die Parallelverarbeitung, um die Verarbeitungsgeschwindigkeit und -effizienz zu verbessern.

Übersicht über die CUDA-Programmierschritte

Die grundlegenden Schritte der CUDA-Programmierung können in den folgenden Teilen zusammengefasst werden:

  1. Kernelfunktionen definieren : Kernelfunktionen sind parallele Codes, die auf der GPU ausgeführt werden, und sie werden als __global__Funktionen definiert. Diese Funktionen arbeiten normalerweise mit einem einzelnen Element des Eingabearrays. Eine einfache Kernelfunktion könnte eine einfache Operation ausführen, beispielsweise das Ändern der Farbe eines Pixels. Eine einfache Kernelfunktion zur Vektoraddition könnte beispielsweise so aussehen:
__global__ void vectorAdd(const float *A, const float *B, float *C, int numElements)
{
    int i = blockDim.x * blockIdx.x + threadIdx.x;

    if (i < numElements)
    {
        C[i] = A[i] + B[i];
    }
}

In diesem Beispiel sind blockIdx.xund threadIdx.xvon CUDA bereitgestellte integrierte Variablen, die den aktuellen Blockindex bzw. Thread-Index darstellen.

  1. Speicher zuweisen und Daten initialisieren : CUDA bietet API-Funktionen wie cudaMalloc()und cudaMemcpy()zum Zuweisen von Speicher auf der GPU bzw. zum Kopieren von Daten von der CPU (auch als Host bezeichnet) auf die GPU (auch als Gerät bezeichnet). Zum Beispiel:
int numElements = 50000;
size_t size = numElements * sizeof(float);
float *d_A = nullptr;
cudaMalloc((void **)&d_A, size);

In diesem Beispiel berechnen wir zunächst die Speichergröße, die zugewiesen werden muss, und verwenden dann cudaMalloc()die Funktion, um Speicher auf der GPU zuzuweisen. d_AIst ein Gerätezeiger, der auf den GPU-Speicher verweist.

  1. Starten Sie die Kernelfunktion : Starten Sie die Kernelfunktion mithilfe <<<...>>>der Syntax. Die Parameter in der Syntax geben die Größe des Pthread-Gitters zum Starten des Kernels an. Zum Beispiel:
int threadsPerBlock = 256;
int blocksPerGrid =(numElements + threadsPerBlock - 1) / threadsPerBlock;
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, numElements);

In diesem Beispiel dimensionieren wir das Thread-Gitter entsprechend der Anzahl der zu verarbeitenden Elemente.

  1. Kopieren Sie das Ergebnis von der GPU zurück auf den Host : Nach Abschluss der Berechnung können Sie cudaMemcpy()das Ergebnis mithilfe einer Funktion vom GPU-Speicher zurück in den CPU-Speicher kopieren. Zum Beispiel:
float *h_C = (float *)malloc(size);
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);

In diesem Beispiel weisen wir zunächst Speicher auf der CPU für das Ergebnisarray zu und verwenden dann cudaMemcpy()eine Funktion, um das Ergebnis von der GPU zurück zur CPU zu kopieren.

  1. Freier Speicher : Schließlich verwenden wir cudaFree()Funktionen, um GPU-Speicher freizugeben, und Standard-C- oder C++-Funktionen, um CPU-Speicher freizugeben. Zum Beispiel:
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
free(h_A);
free(h_B);
free(h_C);

In diesem Beispiel geben wir den gesamten auf GPU und CPU zugewiesenen Speicher frei.

CUDA-Ausführungseinheit – Thread-Block

Im CUDA-Programmiermodell wird Code parallel auf Thread-Ebene geschrieben . Bei der CUDA-Programmierung besteht ein CUDA-Kernel aus vielen Threads (Threads) . Diese Threads können in einem oder mehreren Blöcken (Blöcken) organisiert werden, und diese Blöcke können in einem oder mehreren Gittern (Grids) organisiert werden . Wie nachfolgend dargestellt:

Bild
  • Thread : Thread ist die grundlegendste Ausführungseinheit in CUDA . Jeder Thread führt die gleiche Operation aus, aber die Daten der Operation sind unterschiedlich.
  • BLock : Ein Thread-Block ist eine Sammlung von Threads. Alle Threads teilen sich den gemeinsamen Speicher desselben Thread-Blocks und können durch Synchronisierung innerhalb des Thread-Blocks kommunizieren.
  • Raster : Ein Raster ist eine Sammlung von Thread-Blöcken. Alle Thread-Blöcke im Raster können gleichzeitig ausgeführt werden . Die Threads jedes Thread-Blocks sind unabhängig voneinander und es gibt keine direkte Kommunikation zwischen den Blöcken.

Ein Gitter kann mehrere Blöcke enthalten, Blöcke können eindimensional, zweidimensional oder dreidimensional sein und Threads in einem Block können auch eindimensional, zweidimensional oder dreidimensional sein. Jeder Thread verfügt über eine eindeutige Thread-ID, mit der auf verschiedene Daten und Speicherorte zugegriffen werden kann. Im selben Thread-Block werden Thread-IDs beginnend bei 0 fortlaufend nummeriert, was threadIdxüber integrierte Variablen abgerufen werden kann:

// 获取本线程的索引,blockIdx 指的是线程块的索引,blockDim 指的是线程块的大小,threadIdx 指的是本线程块中的线程索引
int tid = blockIdx.x * blockDim.x + threadIdx.x;    

Bei der CUDA-Programmierung müssen Anzahl und Größe von Blöcken und Threads normalerweise an die Eigenschaften von Rechenaufgaben angepasst werden, um die GPU-Rechenleistung optimal zu nutzen. Beispielsweise können für umfangreiche parallele Rechenaufgaben mehr Threads und Thread-Blöcke verwendet werden, um die parallele Verarbeitungsfähigkeit der GPU voll auszunutzen. Für weniger rechenintensive Aufgaben kann es effizienter sein, weniger Threads und Thread-Blöcke zu verwenden.

// 计算需要的线程总量(高度 x 宽度):640*640=409600
int jobs = dst_height * dst_width;
// 一个线程块包含256个线程
int threads = 256;
// 计算线程块的数量
int blocks = ceil(jobs / (float)threads);

// 调用kernel函数
preprocess_kernel<<<blocks, threads>>>(img_buffer_device, dst, dst_width, dst_height, jobs); // 函数的参数

CUDA-Kernel-Kernel-Kernel-Funktion

Ein Kernkonzept der CUDA-Programmierung ist die Kernelfunktion, bei der es sich um die Entität paralleler Berechnungen handelt, die auf der GPU ausgeführt werden. Diese Funktionen starten Threads, die über eine spezielle Aufrufsyntax parallel auf der GPU ausgeführt werden. Wenn eine Kernelfunktion gestartet wird, führt jeder Thread denselben Code aus, was eine massiv parallele Verarbeitung ermöglicht.

Die Möglichkeit, eine Kernelfunktion zu markieren, besteht darin, __global__das Schlüsselwort zu verwenden, das dem Compiler mitteilt, dass diese Funktion auf der GPU und nicht auf der CPU ausgeführt wird. Ansonsten ähnelt eine Kernelfunktion einer regulären Funktion, kann Eingabe- und Ausgabeparameter, Kontrollfluss und lokale Variablen haben und kann sogar andere Funktionen aufrufen.

Innerhalb der Kernelfunktion stellt CUDA einige integrierte Variablen bereit, z. B. threadIdx, blockIdxund blockDim, die uns helfen können, die spezifische Position und den Kontext jedes Threads zu verstehen. Mithilfe dieser Variablen können Sie den Ausführungspfad paralleler Aufgaben effektiv steuern.

Um eine Kernelfunktion zu starten, ist eine spezielle Syntax erforderlich <<<...>>>. In dieser Syntax dient der erste Parameter zur Angabe der Anzahl der Thread-Blöcke (Block) und der zweite Parameter zur Angabe der Anzahl der Threads in jedem Thread-Block. Diese Parameter können eine Ganzzahl oder ein dim3Typ sein. Letzterer ermöglicht die Angabe der Anzahl der Threads in x-, y- und z-Richtung . Wenn nur eine Ganzzahl angegeben wird, wird diese als Anzahl der Threads in x-Richtung interpretiert, während die Anzahl der Threads in y- und z-Richtung standardmäßig 1 beträgt.

Hier ist ein einfaches Beispiel, eine einfache Vektoradditions-Kernelfunktion:

// 向量加法
__global__ void add(int *a, int *b, int *c, int N)
{
    
       
    // 获取本线程块的索引,blockIdx 指的是线程块的索引,blockDim 指的是线程块的大小,threadIdx 指的是线程的索引
    int tid = blockIdx.x * blockDim.x + threadIdx.x;    
    if (tid < N)
        c[tid] = a[tid] + b[tid];
}

Die Funktion ist hier addmit gekennzeichnet, __global__um anzuzeigen, dass es sich um eine Kernelfunktion handelt. Innerhalb der Funktion verwenden wir blockIdx.x, blockDim.xund, threadIdx.xum eine eindeutige Thread-ID zu berechnen, sodass jeder Thread ein Element unabhängig verarbeiten kann.

Dann können wir diese Kernelfunktion wie folgt aufrufen:

// 调用kernel函数
add<<<n_blocks, n_threads>>>(dev_a, dev_b, dev_c, N); 

Darunter sind n_blocksund n_threadsjeweils die Anzahl der Thread-Blöcke und die Anzahl der Threads in jedem Thread-Block, und , dev_aund sind Parameter, die an die Kernelfunktion übergeben werden.dev_bdev_cN

Beispielcode für die CUDA-Kernelfunktion

Dieser CUDA-Code demonstriert hauptsächlich, wie CUDA zum Durchführen einfacher paralleler Berechnungen verwendet wird, und vergleicht den Zeitunterschied zwischen CPU und GPU für dieselbe Berechnung. Führen Sie hauptsächlich die folgenden Vorgänge aus:

  1. Definieren Sie eine GPU-Parallelfunktion (Kernelfunktion)add : Der Zweck dieser Funktion besteht darin, elementweise zu zwei ganzzahligen Arrays hinzuzufügen. Die Funktion benötigt zwei Eingabearrays aund bein Ausgabearray c, die alle auf der GPU gespeichert sind. Gleichzeitig akzeptiert die Funktion auch einen Parameter, Nder die Größe des Arrays angibt. In der Funktion wird der Index jedes Threads durch den Index des Thread-Blocks bestimmt tid, in dem sich der aktuelle Thread befindet blockIdx.x, die Größe des Thread-Blocks blockDim.xund den Index des Threads im Thread-Block, in dem er sich befindet . threadIdx.xWenn tidkleiner als , fügt Nder Thread die entsprechenden Elemente in aund in hinzu und speichert sie.bc
  2. mainFühren Sie eine Reihe von Operationen in einer Funktion aus :
    • Befehlszeilenargumente prüfen : Wenn die Anzahl der Befehlszeilenargumente nicht 2 beträgt, gibt das Programm eine Fehlermeldung aus und wird beendet.
    • Daten initialisierena : Initialisieren Sie zunächst zwei Arrays und Summen im CPU-Speicher b, wobei der Wert jedes Elements seinem Index entspricht. Verwenden Sie dann die Funktion , um dem Array Speicherplatz im GPU - cudaMallocSpeicher zuzuweisen a.bc
    • Array-Hinzufügen auf der CPUa : Das Programm fügt zunächst das Array und bdie Elemente einzeln auf der CPU hinzu und speichert das Ergebnis im Array c. Verwenden Sie außerdem CUDA-Ereignisse, um die verstrichene Zeit dieses Prozesses zu messen.
    • Array-Hinzufügen auf der GPU : Das Programm kopiert den Inhalt der Array-Summe avom bCPU-Speicher in den GPU-Speicher und ruft dann eine GPU-Parallelfunktion auf, addum die Summe elementweise zu addieren und das Ergebnis ain zu speichern . Die Funktion verwendet eine GPU-Parallelkonfiguration, die als Thread-Blöcke konfiguriert ist, wobei jeder Thread-Block Threads enthält . Gleichzeitig nutzt das Programm CUDA-Ereignisse, um die verstrichene Zeit dieses Prozesses zu messen.bcaddn_blocksn_threads
    • Überprüfen Sie, ob die Berechnungsergebnisse von CPU und GPU konsistent sind : Das Programm kopiert das Berechnungsergebnis auf der GPU zurück in den CPU-Speicher und prüft, ob es mit dem Berechnungsergebnis auf der CPU konsistent ist.
    • GPU-Speicher freigeben : Schließlich verwendet das Programm Funktionen, um den cudaFreeauf der GPU zugewiesenen Speicher für a, bund freizugeben.c
#include <stdio.h>

__global__ void add(int *a, int *b, int *c, int N)
{
    
    
    // 获取本线程的索引,blockIdx 指的是线程块的索引,blockDim 指的是线程块的大小,threadIdx 指的是本线程块中的线程索引
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    // printf("tid: %d blockIdx.x: %d blockDim.x: %d threadIdx.x: %d \n", tid, blockIdx.x, blockDim.x, threadIdx.x);
    if (tid < N)
        c[tid] = a[tid] + b[tid];
}

int main(int argc, char **argv)
{
    
    
    // 检查命令行参数
    if (argc != 2)
    {
    
    
        fprintf(stderr, "Usage: ./test <N>");
    }
    int N = std::atoi(argv[1]);
    int a[N], b[N], c[N], c_from_gpu[N];
    int *dev_a, *dev_b, *dev_c;

    // 在设备端分配内存
    cudaMalloc((void **)&dev_a, N * sizeof(int));
    cudaMalloc((void **)&dev_b, N * sizeof(int));
    cudaMalloc((void **)&dev_c, N * sizeof(int));

    // 初始化数组
    for (int i = 0; i < N; i++)
    {
    
    
        a[i] = i;
        b[i] = i;
    }

    // 统计CPU上运行时间
    cudaEvent_t start, stop;
    cudaEventCreate(&start);
    cudaEventCreate(&stop);
    cudaEventRecord(start, 0);
    for (int i = 0; i < N; i++)
    {
    
    
        c[i] = a[i] + b[i];
    }
    cudaEventRecord(stop, 0);
    cudaEventSynchronize(stop);
    float time;
    cudaEventElapsedTime(&time, start, stop);
    printf("Time spent on CPU: %f ms\n", time);

    // 将数据从主机端复制到设备端
    cudaMemcpy(dev_a, a, N * sizeof(int), cudaMemcpyHostToDevice);
    cudaMemcpy(dev_b, b, N * sizeof(int), cudaMemcpyHostToDevice);

    // 调用kernel函数,在GPU上运行并发计算
    // 一个线程块包含256个线程
    int n_threads = 256;
    // 计算线程块的数量
    int n_blocks = std::ceil(N * 1.0f / n_threads);

    // 统计时间
    cudaEventRecord(start, 0);
    // 调用kernel函数,传递线程块数量和大小
    add<<<n_blocks, n_threads>>>(dev_a, dev_b, dev_c, N); 
    cudaEventRecord(stop, 0);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&time, start, stop);
    printf("Time spent on GPU: %f ms\n", time);

    // 将数据从设备端复制到主机端
    cudaMemcpy(c_from_gpu, dev_c, N * sizeof(int), cudaMemcpyDeviceToHost);

    // 检查结果是否一致
    for (int i = 0; i < N; i++)
    {
    
    
        if (c[i] != c_from_gpu[i])
        {
    
    
            printf("Error: inconsistent results!\n");
        }
    }

    // 释放设备端内存
    cudaFree(dev_a);
    cudaFree(dev_b);
    cudaFree(dev_c);

    return 0;
}
# ./build/test 500000
Time spent on CPU: 2.163136 ms
Time spent on GPU: 0.029248 ms
createInferRuntime
Runtime创建成功
deserializeCudaEngine
Engine创建成功
createExecutionContext
Context创建成功
BufferManager类
缓冲区创建成功
VideoCapture类
cuda_preprocess_init
视频读取成功
GPU内存申请成功
process_input
executeV2
copyOutputToHost
getHostBuffer
yolo_nms
创建推理运行时的runtime
读取模型文件并反序列生成engine
创建执行上下文
创建输入输出缓冲区
读入视频与申请GPU内存
图像预处理 推理与结果输出
IRuntime 实例
优化阶段和推理阶段
ICudaEngine 实例
推理的执行
IExecutionContext 实例
执行推理
输入/输出缓冲区
执行模型推理, 并获取推理结果
读入视频流
申请GPU内存
准备视频推理
利用GPU进行处理
图像预处理
模型推理
结果提取
从buffer中提取推理结果
非极大值抑制,得到最后的检测框

Zusammenfassen

Wenn Ihnen unser Artikel gefällt oder Sie den vollständigen Text des Quellcodes benötigen, können Sie dem öffentlichen vertikalen VX-Konto folgen: 01 Programming House. Senden Sie tensorrt, um den vollständigen Text des Quellcodes zu erhalten.

Guess you like

Origin blog.csdn.net/weixin_43654363/article/details/131886371