热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

PytorchCUDA从入门到放弃(二)

在顺利运行官方DEMO后,我们开始学习如何使用C++与CUDA进行交互。高层抽象只需要我们考虑WHAT,越到底层越需要我们了解HOW。全部用底层实现会让我们无法聚焦核心的工作。因此

在顺利运行官方DEMO后,我们开始学习如何使用 C++ 与 CUDA 进行交互。

高层抽象只需要我们考虑 WHAT,越到底层越需要我们了解 HOW。全部用底层实现会让我们无法聚焦核心的工作。因此,我们每次只选择比较重要的部分,用更底层的实现进行优化。

这次,让我们从 C++ front end 开始,逐步深入到 CUDA 实现。

C++ front end

在学习 C++ 调用 CUDA 之前,我们先了解一下 C++ 的高层封装,C++ front end。C++ front end 是 Pytorch 的 C++ 版。pytorch 利用 CPython 在它的基础上添加了一个胶水层,使我们能够用 Python 调用这些方法。

让我们来看一个简单的例子,首先,引入包:

python:
import torch
C++:
#include

建立模型:

python:
model = torch.nn.Linear(5, 1)
C++:
auto model = torch::nn::Linear(5, 1);

声明损失函数,并进行正向传播和反向传播:

python:
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
prediction = model.forward(torch.randn(3, 5))
loss = torch.nn.functional.mse_loss(prediction, torch.ones(3, 1))
loss.backward()
optimizer.step()
C++:
auto optimizer = torch::optim::SGD(model->parameters(), /*lr=*/0.1);
auto prediction = model->forward(torch::randn({3, 5}));
auto loss = torch::mse_loss(prediction, torch::ones({3, 1}));
loss.backward();
optimizer.step();

和 Pytorch 一样,在 C++ front end 中,我们无需关心 opt 的计算方式,也不需要考虑如何调用 GPU ,简单地建立模型结构,C++ front end 就会帮助我们解决这些问题。如果你想看更复杂的例子,请查阅官方文档。

ATEN

现在,如果我们要自己定义一些操作,我们就会用到 C++ front end 的一些底层库。其中最为重要的是 ATEN 和 autograd。

ATEN 是一个 Tensor 库,它将数组封装为一个 Tensor 类(就像 numpy 把数组封装成 nparray)。它在 CPU 和 GPU 上,为我们提供了创建数组和操作数组的方法(没错,和 Pytorch 中的 Tensor 一样)。例如,我们可以这样使用:

#include
// 声明两个 Tensor 并相加
at::Tensor a = at::randn({2, 2}, at::kInt);
at::Tensor b = at::randn({2, 2}, at::kInt);
auto c = a + b;
// 在 GPU 声明两个 Tensor 并相加
at::Tensor a = CUDA(at::kFloat).ones({3, 4});
at::Tensor b = CUDA(at::kFloat).zeros({3, 4});
auto c = a + b;

如果只用 ATEN 库,我们需要自己实现反向传播(微分)。如果不想这么麻烦,我们就要引入 Autograd 了。它封装了 ATEN 的所有 Tensor 操作,为它们添加了自动微分的功能,使用起来和 Pytorch 相同。

#include
#include
#include
at::Tensor a = torch::randn({2, 2}, at::requires_grad());
at::Tensor b = torch::randn({2, 2});
auto c = a + b;
c.backward();

到这里,所有 Pytorch 的功能我们都可以找到 C++ 的对应实现了。如果你想了解更详细的内容,请查阅官方文档。让我打开Pytorch-CUDA从入门到放弃(一)中下载的官方 DEMO。我们应该已经可以看懂 LLTM 的正向传递(和反向传递)的 C++ 实现:

#include
std::vector lltm_forward(
at::Tensor input,
at::Tensor weights,
at::Tensor bias,
at::Tensor old_h,
at::Tensor old_cell) {
auto X = at::cat({old_h, input}, /*dim=*/1);
// ========== C++ 实现 LLTM ==========
auto gate_weights = at::addmm(bias, X, weights.transpose(0, 1));
auto gates = gate_weights.chunk(3, /*dim=*/1);
auto input_gate = at::sigmoid(gates[0]);
auto output_gate = at::sigmoid(gates[1]);
auto candidate_cell = at::elu(gates[2], /*alpha=*/1.0);
auto new_cell = old_cell + candidate_cell * input_gate;
auto new_h = at::tanh(new_cell) * output_gate;
// ========== C++ 实现 LLTM ==========
return {new_h,
new_cell,
input_gate,
output_gate,
candidate_cell,
X,
gate_weights};
}

下面,我们开始学习使用 CUDA 函数替换上面代码中 C++ 实现的 LLTM,也就是手动操作 GPU 进行计算。

CUDA 基础

在使用 CUDA 之后,我们获得了 GPU 的控制权,现在在编写代码时需要指明是 CPU 还是 GPU 进行数据运算。我们可以简单的将数据运算(即函数的调用方式)分为三种:

  1. global 在 CPU 调用函数,函数在 GPU 执行(异步)
  2. device 在 GPU 调用函数,函数在 GPU 执行
  3. host 在 CPU 调用函数,函数在 CPU 执行(同步)

《Pytorch-CUDA从入门到放弃(二)》
《Pytorch-CUDA从入门到放弃(二)》 函数的调用方式

CUDA 在 C 语言的基础上添加了三个关键字区分三种不同的函数,我们现在需要这样声明:

__global__ void MyFunc(float func_input)
{
// DO SOMETHING
}
__host__ void MyFunc(int func_input)
{
// DO SOMETHING
}
__device__ void MyFunc(byte func_input)
{
// DO SOMETHING
}

__global__ 和 __device__ 声明的函数,在调用时会被分配给 CUDA 中众多的核,在多个线程中执行。因此在调用函数时,我们需要告诉 GPU,哪些线程要执行该函数。由于 GPU 的线程太多了,因此我们为 GPU 的线程划分了国(grid)-省(block)-市(thread)的分级。

《Pytorch-CUDA从入门到放弃(二)》
《Pytorch-CUDA从入门到放弃(二)》 一个grid

在一个 grid 中也有很多 block。让我们来声明一个有 4*4 个 block 的 grid:

// dim3 代表一个三元组 ,我们可以拿到 x y 和 z
// 在学习过程中我们只考虑二维问题,因此只定义 x 和 y
dim3 grid(4, 4);

这时候深绿色 block 有自己的位置:

// 第一行 第一列
blockId.x = 1;
blockId.y = 1;

一个 block 中有很多 thread。让我们定义一个有 4*4 个 thread 的 block:

// dim3 代表一个三元组 ,我们可以拿到 grad.x grad.y 和 grid.z
// 在学习过程中我们只考虑二维问题,因此只定义 x 和 y
dim3 block(4, 4);

这时候 thread 也有自己的位置。让我们看一下浅绿色的 Thread 的位置:

// block 第一行 第四列
blockId.x = 1;
blockId.y = 4;
// thread 第一行 第一列
threadId.x = 1;
threadId.y = 1;

现在,你可以让一个函数去管理自己的线程们了。还记得我们之前讨论的吗,要在 main 中(CPU 中)调用 GPU 进行计算,我们要用 global 关键字修饰。在调用函数的时候需要为函数(按级别)分配 GPU 线程:

// 定义
__global__ void MyFunc(float func_input)
{
DO SOMETHING
}
int main()
{
...
// 领土范围
dim3 threadsPerBlock(16, 16);
dim3 numBlocks(16, 16);
// 调用
MatAdd<<>>(A, B, C);
...
}

在 MyFunc 中,CUDA 已经为我们注入了关键字 blockId 和 threadId 用于获取 thread 的位置,在矩阵运算中,我们通常会将矩阵中的元素与 GPU 中的 thread 一一对应:

__global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N])
{
// 这里就获取了当前市 thread 的位置
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
// 根据位置 thread 情况计算
if (i C[i][j] = A[i][j] + B[i][j];
}

CPU 的内润和 GPU 的内存是两个独立的空间。我们现在已经能够通过 global function(kernal)指定 GPU 对 GPU 内存上的数据进行加工了。然而,我们怎样把 CPU 内存的数据传送到 GPU 内存,又怎样传输回来呢。

我们先看一下 global function 能运过去什么,运回来什么:

__global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N]) ;

global 函数的输入是有限的,因此无法用来传输数组(的内容),但是可以用来传递数组的(CPU 内存或 GPU 内存)地址。global 函数的返回时 void,没有什么用。

因此我们需要一个接口,把 CPU 内存上的数据传送到 GPU 内存,然后告诉我们 GPU 内存上的位置。我们就可以通过 global function 对指定 GPU 内存的数据进行操作了。CUDA 是这样实现的:通过 cudaMalloc 在 GPU 上申请一块空间并获得空间的地址,再通过 cudaMemcpyHostToDevice 把数据放在这块空间(利用前面获得的地址),最后再把数据的地址(就是前面获得的地址)作为输入传递给 global function。

float *func_input_in_device;
float func_input[] = [...]
cudaMalloc((void**)&func_input_in_device, nBytes);
cudaMemcpy((void*)func_input_in_device, (void*)x, nBytes, cudaMemcpyHostToDevice);
dim3 blockSize(16,16);
dim3 gridSize(16,16);
MyFunc <<>>(func_input_in_device);

获得返回也是一样,通过 cudaMalloc 在 GPU 上申请一块空间并获得空间的地址,再把这块空间的地址(就是前面获得的地址)作为输入传递给 global function 留给 GPU 填充结果,最后再通过 cudaMemcpyDeviceToHost 把地址指定的数据拷贝回来。

float *func_input_in_device;
cudaMalloc((void**)&func_input_in_device, nBytes);
cudaMemcpy((void*)func_input_in_device, (void*)x, nBytes, cudaMemcpyHostToDevice);
float *func_output_in_device;
cudaMalloc((void**)&func_output_in_device, nBytes);
float *func_output
func_outputs = (float*)malloc(nBytes);
dim3 blockSize(16,16);
dim3 gridSize(16,16);
MyFunc <<>>(func_input_in_device, func_output_in_device);
cudaMemcpy((void*)func_output, (void*)func_output_in_device, nBytes, cudaMemcpyDeviceToHost);

你可能注意到,我们之前强调过,的计算是异步的。你是否觉得 cudaMemcpy 不一定会拿到我们期望的计算结果?其实,运算过程是这样的:

MyFunc1 <<<...>>>(...);
// MyFunc1加入GPU的任务队列,CPU不等待GPU的执行结果继续向下执行
MyFunc2 <<<...>>>(...);
//MyFunc2加入GPU的任务队列,等待MyFunc2执行完毕后执行,CPU不等待GPU的执行结果继续向下执行
cudaMemcpy(...);
// CPU被阻塞,等待GPU完成任务队列中所有任务后开始从GPU拷贝数据,直到拷贝完成再向下执行

由于这样写太复杂(需要来回拷贝),因此 CUDA 提供了一个语法糖进行简化。我们可以直接使用 cudaMallocManaged 开辟一个 CPU 和 GPU 都能访问到的公共空间。使用这个接口,我们不再需要手动对数据进行复制,但是其实原理和上面相同。

float *func_input, *func_output;
cudaMallocManaged(&func_input, nBytes);
cudaMallocManaged(&func_output, nBytes);
for (int i = 0; i func_input[i] = x[i];
}
MyFunc <<>>(func_input, func_output);
// CPU 可以拿到 func_output

需要注意的是,GPU 和公共区域上开辟的空间不会自动释放,需要我们手动调用 cudaFree 释放:

cudaFree(func_input)
cudaFree(func_output)

其实,这部分内容并不常用,因为大部分时候我们都会直接对 Tensor.data 进行操作生成一个结果赋给另一个 Tensor.data,而 Tensor.data 是被 ATEN 分配在 GPU 上的,也就不涉及到和 CPU 进行数据交换的问题了。

CUDA 库

在 CPU 上我们有各种各样的函数库,然而这些函数库无法直接在 GPU 上(global function里)调用。不过不要担心,CUDA 本身为我们提供了丰富的函数库。

我们常用的数学运算在 CUDA math 中:

#include
#include
template
__device__ __forceinline__ scalar_t sigmoid(scalar_t z) {
return 1.0 / (1.0 + exp(-z));
// exp 函数
}

矩阵运算在 cuBLAS 中:

...
// 创建 handle
cublasHandle_t handle;
cublasCreate(&handle);
// 调用函数,传入计算所需参数
cublasSgemm(handle,CUBLAS_OP_N,CUBLAS_OP_N,1,3,2,&alpha,d_b,1,d_a,2,&beta,d_c,1);

利用这些库,我们可以将 LLTM 用到的操作用 CUDA 重构:

template
__device__ __forceinline__ scalar_t d_sigmoid(scalar_t z) {
const auto s = sigmoid(z);
return (1.0 - s) * s;
}
template
__device__ __forceinline__ scalar_t d_tanh(scalar_t z) {
const auto t = tanh(z);
return 1 - (t * t);
}
template
__device__ __forceinline__ scalar_t elu(scalar_t z, scalar_t alpha = 1.0) {
return fmax(0.0, z) + fmin(0.0, alpha * (exp(z) - 1.0));
}
template
__device__ __forceinline__ scalar_t d_elu(scalar_t z, scalar_t alpha = 1.0) {
const auto e = exp(z);
const auto d_relu = z <0.0 ? 0.0 : 1.0;
return d_relu + (((alpha * (e - 1.0)) <0.0) ? (alpha * e) : 0.0);
}
template
__global__ void lltm_cuda_forward_kernel(
const scalar_t* __restrict__ gates,
const scalar_t* __restrict__ old_cell,
scalar_t* __restrict__ new_h,
scalar_t* __restrict__ new_cell,
scalar_t* __restrict__ input_gate,
scalar_t* __restrict__ output_gate,
scalar_t* __restrict__ candidate_cell,
size_t state_size) {
const int column = blockIdx.x * blockDim.x + threadIdx.x;
const int index = blockIdx.y * state_size + column;
const int gates_row = blockIdx.y * (state_size * 3);
if (column input_gate[index] = sigmoid(gates[gates_row + column]);
output_gate[index] = sigmoid(gates[gates_row + state_size + column]);
candidate_cell[index] = elu(gates[gates_row + 2 * state_size + column]);
new_cell[index] =
old_cell[index] + candidate_cell[index] * input_gate[index];
new_h[index] = tanh(new_cell[index]) * output_gate[index];
}
}

ATEN 与 CUDA 交互

ATEN 与 CUDA 交互实际上就是在解决 global function 的输入输出问题。我们需要将 ATEN 声明的 Tensor 转换成 global function 可以接受的数据,在 global function 处理后再将其输出转化为 ATEN 可以接受的形式。

值得庆幸的是,ATEN 的数据(CUDA Tensor)和 global function 的计算结果都在 GPU 中。因此不涉及到拷贝或是公共内存的问题。唯一需要考虑的是,ATEN 的数据数据类型和 global function 不同。

ATEN 为我们提供了接口函数 AT_DISPATCH_FLOATING_TYPES。这个函数接收三个参数,第一个参数是输入数据的源类型,第二个参数是操作的标识符(用于报错显示),第三个参数是一个匿名函数。在匿名函数运行结束后,AT_DISPATCH_FLOATING_TYPES 会将 Float 数组转化为目标类型(运行中的实际类型)数组。

有些同学可能不了解 C++ 的匿名函数,其实就是一个省略了函数名称的函数:

[](int x, int y) { return x + y; }
// [配置](参数){程序体}
[&](int x, int y) { return x + y; }
// 参数按引用传递
[=](int x, int y) { return x + y; }
// 参数按值传递

AT_DISPATCH_FLOATING_TYPES 中的匿名函数中可以使用 scalar_t 代指目标类型。而 ATEN 支持我们使用 Tensor.data<类型> 将 Tensor.data 转换为某个类型。因此,可以这样转换:

AT_DISPATCH_FLOATING_TYPES(gates.type(), "lltm_forward_cuda", ([&] {
lltm_cuda_forward_kernel<<>>(
gates.data(),
old_cell.data(),
new_h.data(),
new_cell.data(),
input_gate.data(),
output_gate.data(),
candidate_cell.data(),
state_size);
}));

到这里,我们已经可以把原来 C++ 实现的 forward 中核心的部分替换为 CUDA 实现了:

std::vector lltm_cuda_forward(
at::Tensor input,
at::Tensor weights,
at::Tensor bias,
at::Tensor old_h,
at::Tensor old_cell) {
auto X = at::cat({old_h, input}, /*dim=*/1);
auto gates = at::addmm(bias, X, weights.transpose(0, 1));
const auto batch_size = old_cell.size(0);
const auto state_size = old_cell.size(1);
auto new_h = at::zeros_like(old_cell);
auto new_cell = at::zeros_like(old_cell);
auto input_gate = at::zeros_like(old_cell);
auto output_gate = at::zeros_like(old_cell);
auto candidate_cell = at::zeros_like(old_cell);
const int threads = 1024;
const dim3 blocks((state_size + threads - 1) / threads, batch_size);
AT_DISPATCH_FLOATING_TYPES(gates.type(), "lltm_forward_cuda", ([&] {
lltm_cuda_forward_kernel<<>>(
gates.data(),
old_cell.data(),
new_h.data(),
new_cell.data(),
input_gate.data(),
output_gate.data(),
candidate_cell.data(),
state_size);
}));
return {new_h, new_cell, input_gate, output_gate, candidate_cell, X, gates};
}

如果你有耐心看到这里,很快就能入门啦~ 有些同学私信我官方文档的 Demo 比较复杂,所以我手写了 Dense 扩展上传在了 Github 上面。讲解从 Python extension 优化到 CPP extension 再到 CUDA extension 的过程。感兴趣的同学可以照着实现一遍,有什么问题可以提 issue 或者留言。重复一下:

所以我手写了 Dense 扩展上传在了 Github 上面

所以我手写了 Dense 扩展上传在了 Github 上面

所以我手写了 Dense 扩展上传在了 Github 上面

在下一部分中,我们会学习将写好的 C++/CUDA 代码接入 python 的多种方式,并对代码进行自顶向下的单元测试和 DEBUG。


推荐阅读
  • 在稀疏直接法视觉里程计中,通过优化特征点并采用基于光度误差最小化的灰度图像线性插值技术,提高了定位精度。该方法通过对空间点的非齐次和齐次表示进行处理,利用RGB-D传感器获取的3D坐标信息,在两帧图像之间实现精确匹配,有效减少了光度误差,提升了系统的鲁棒性和稳定性。 ... [详细]
  • 【图像分类实战】利用DenseNet在PyTorch中实现秃头识别
    本文详细介绍了如何使用DenseNet模型在PyTorch框架下实现秃头识别。首先,文章概述了项目所需的库和全局参数设置。接着,对图像进行预处理并读取数据集。随后,构建并配置DenseNet模型,设置训练和验证流程。最后,通过测试阶段验证模型性能,并提供了完整的代码实现。本文不仅涵盖了技术细节,还提供了实用的操作指南,适合初学者和有经验的研究人员参考。 ... [详细]
  • 本文提供了PyTorch框架中常用的预训练模型的下载链接及详细使用指南,涵盖ResNet、Inception、DenseNet、AlexNet、VGGNet等六大分类模型。每种模型的预训练参数均经过精心调优,适用于多种计算机视觉任务。文章不仅介绍了模型的下载方式,还详细说明了如何在实际项目中高效地加载和使用这些模型,为开发者提供全面的技术支持。 ... [详细]
  • 本文详细介绍了在Windows操作系统上使用Python 3.8.5编译支持CUDA 11和cuDNN 8.0.2的TensorFlow 2.3的步骤。文章不仅提供了详细的编译指南,还分享了编译后的文件下载链接,方便用户快速获取所需资源。此外,文中还涵盖了常见的编译问题及其解决方案,确保用户能够顺利进行编译和安装。 ... [详细]
  • 本文提出了一种基于栈结构的高效四则运算表达式求值方法。该方法能够处理包含加、减、乘、除运算符以及十进制整数和小括号的算术表达式。通过定义和实现栈的基本操作,如入栈、出栈和判空等,算法能够准确地解析并计算输入的表达式,最终输出其计算结果。此方法不仅提高了计算效率,还增强了对复杂表达式的处理能力。 ... [详细]
  • 深入解析Struts、Spring与Hibernate三大框架的面试要点与技巧 ... [详细]
  • PyTorch实用技巧汇总(持续更新中)
    空洞卷积(Dilated Convolutions)在卷积操作中通过在卷积核元素之间插入空格来扩大感受野,这一过程由超参数 dilation rate 控制。这种技术在保持参数数量不变的情况下,能够有效地捕捉更大范围的上下文信息,适用于多种视觉任务,如图像分割和目标检测。本文将详细介绍空洞卷积的计算原理及其应用场景。 ... [详细]
  • 在Conda环境中高效配置并安装PyTorch和TensorFlow GPU版的方法如下:首先,创建一个新的Conda环境以避免与基础环境发生冲突,例如使用 `conda create -n pytorch_gpu python=3.7` 命令。接着,激活该环境,确保所有依赖项都正确安装。此外,建议在安装过程中指定CUDA版本,以确保与GPU兼容性。通过这些步骤,可以确保PyTorch和TensorFlow GPU版的顺利安装和运行。 ... [详细]
  • Java Socket 关键参数详解与优化建议
    Java Socket 的 API 虽然被广泛使用,但其关键参数的用途却鲜为人知。本文详细解析了 Java Socket 中的重要参数,如 backlog 参数,它用于控制服务器等待连接请求的队列长度。此外,还探讨了其他参数如 SO_TIMEOUT、SO_REUSEADDR 等的配置方法及其对性能的影响,并提供了优化建议,帮助开发者提升网络通信的稳定性和效率。 ... [详细]
  • 在C语言中,指针的高级应用及其实例分析具有重要意义。通过使用 `&` 符号可以获取变量的内存地址,而 `*` 符号则用于定义指针变量。例如,`int *p;` 定义了一个指向整型的指针变量 `p`。其中,`p` 代表指针变量本身,而 `*p` 则表示指针所指向的内存地址中的内容。此外,指针在不同函数中可以具有相同的变量名,但其作用域和生命周期会有所不同。指针的灵活运用能够有效提升程序的效率和可维护性。 ... [详细]
  • 在C语言程序开发中,调试和错误分析是确保代码正确性和效率的关键步骤。本文通过一个简单的递归函数示例,详细介绍了如何编写和调试C语言程序。具体而言,我们将创建一个名为 `factorial.c` 的文件,实现计算阶乘的功能,并通过逐步调试来分析和解决可能出现的错误。此外,文章还探讨了常见的调试工具和技术,如GDB和断点设置,以帮助开发者高效地定位和修复问题。 ... [详细]
  • 题目要求维护一个数列,并支持两种操作:一是查询操作,语法为QL,用于查询数列末尾L个数中的最大值;二是更新操作,用于修改数列中的某个元素。本文通过ST表(Sparse Table)优化查询效率,确保在O(1)时间内完成查询,同时保持较低的预处理时间复杂度。 ... [详细]
  • PAT乙级1001题探讨了著名的(3n+1)猜想,该问题在数学和计算机科学中具有重要地位。本文详细解析了该猜想的算法实现,并通过C语言代码示例展示了如何验证这一猜想。通过对输入整数进行一系列变换,最终证明所有正整数都会进入4-2-1循环。该题不仅考察了基本编程能力,还涉及了递归和迭代等核心概念。 ... [详细]
  • 2018 HDU 多校联合第五场 G题:Glad You Game(线段树优化解法)
    题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=6356在《Glad You Game》中,Steve 面临一个复杂的区间操作问题。该题可以通过线段树进行高效优化。具体来说,线段树能够快速处理区间更新和查询操作,从而大大提高了算法的效率。本文详细介绍了线段树的构建和维护方法,并给出了具体的代码实现,帮助读者更好地理解和应用这一数据结构。 ... [详细]
  • 能够感知你情绪状态的智能机器人即将问世 | 科技前沿观察
    本周科技前沿报道了多项重要进展,包括美国多所高校在机器人技术和自动驾驶领域的最新研究成果,以及硅谷大型企业在智能硬件和深度学习技术上的突破性进展。特别值得一提的是,一款能够感知用户情绪状态的智能机器人即将问世,为未来的人机交互带来了全新的可能性。 ... [详细]
author-avatar
贾春雨-cherry
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有