1. 程式人生 > 實用技巧 >【LWJGL3】LWJGL3 是怎樣在棧上進行記憶體分配的

【LWJGL3】LWJGL3 是怎樣在棧上進行記憶體分配的

簡介

LWJGL是一個支援OpenGL,Opengl ES,Vulkan等圖形API的Java繫結庫。通過JNI與特定平臺上的這些本地庫繫結起來,使得可以使用Java進行相關的應用開發,同時還實現了跨平臺的統一的API。

遊戲/圖形學相關的開發,目前看主要還是C/C++的領域,使用Java來進行開發較為少見。除了生態方面的原因之外,效能方面的考慮也是其中一個方面的原因。

本文將討論 LWJGL繫結庫圍繞MemoryStack類設計的,高效能的記憶體分配方式。

LWJGL3 需要解決的問題

在原生c語言的opengl,有如下常用操作

GLuint vbo;
glGenBuffers(1, &vbo);

這段程式碼是用於請求顯示卡分配儲存空間,這是opengl中非常常用的一個操作。

該API的官方文件見 http://docs.gl/gl4/glGenBuffers

而在LWJGL中要完成這個原本簡單的操作,也就是說要實現一個Java版本的glGenBuffers(int num,long vbo)函式,該怎麼做呢。

由於Java不存在通過&一個變數進行取地址值的語法,因此只能傳遞long型別的值作為地址,該地址指向的是一個堆外緩衝區。

那麼Java如何做到,在堆外分配緩衝區,同時獲取到這個緩衝區的地址值?

我們可以通過 ByteBuffer buffer = ByteBuffer.allocateDirect(4) 來獲取一個堆外緩衝區,但是我們還需要這塊緩衝區的地址
因此我們需要這樣一個方法,它能夠獲取一個堆外的指定大小的緩衝區的地址,如下這個函式的原型就是符合需求的

public static long getVboBufferAddress(ByteBuffer directByteBuffer)

函式原型有了,但是到底怎麼實現呢。ByteBuffer 的地址,其實是儲存在該物件的欄位中,在我使用的Oracle JDK 8平臺上,該欄位名為 addres。

如果不考慮跨平臺(該欄位的名字在不同的JDK實現裡並不相同),可以做如下實現,來獲取address的值:

public static long getVboBufferAddress1(ByteBuffer directByteBuffer) throws NoSuchFieldException {
        Field address 
= Buffer.class.getDeclaredField("address"); long addressOffset = UNSAFE.objectFieldOffset(address); long addr = UNSAFE.getLong(directByteBuffer, addressOffset); return addr; }

這裡的UNSAFE是我在工具類裡定義的一個私有靜態變數,該工具類的完整程式碼附在文末,類名為MyMemUtil

如果要考慮跨平臺,則因為 address 欄位名在不同平臺的不同,就無法使用 Unsafe類的 objectFieldOffset 的方法了。

針對這個問題的思路是,只要能找到這個欄位在物件中的偏移量,那麼無論在什麼平臺上,我們都能通過Unsafe::getLong(obj, offset)方法來獲取到這個欄位的值。於是問題變成了,如何獲取該欄位在物件中的偏移量,這可以採用如下步驟來做到:

  1. 先使用 JNI 提供的 的 NewDirectByteBuffer 函式,在指定地址上獲取一塊容量為0的直接堆外直接緩衝區,這裡有兩點需要理解:
    1. 因為地址是指定的,所以該地址值是一個魔法值。
    2. 之所以容量為0,是因為這塊緩衝區並不是要用來存東西,而只是輔助用於尋找 “address”(當然實際可能不是這個名字)欄位在物件中的偏移量。
  2. 對返回的 ByteBuffer 物件進行掃描,由於該物件裡肯定有個欄位的值等於我們的魔法值,因此使用魔法值進行逐個比較,就能找到該欄位,同時也就找到了該欄位在物件中的偏移量。

具體實現如下(這裡的魔法值,直接採用了上面程式碼中一次執行結果的 addr的值)

/**
     * 考慮跨平臺的情況
     *
     * @param directByteBuffer
     * @return
     * @throws NoSuchFieldException
     */
    public static long getVboBufferAddress2(ByteBuffer directByteBuffer) throws NoSuchFieldException {
        long MATIC_ADDRESS = 720519504;
        ByteBuffer helperBuffer = newDirectByteBuffer(MATIC_ADDRESS, 0);
        long offset = 0;
        while (true) {
            long candidate = UNSAFE.getLong(helperBuffer, offset);
            if (candidate == MATIC_ADDRESS) {
                break;
            } else {
                offset += 8;
            }
        }

        long addr = UNSAFE.getLong(directByteBuffer, offset);
        return addr;
    }

這個程式碼是能夠正常發揮作用的,在下已經在自己的專案中進行了驗證。

但是這裡有兩個細節在下也不是很明白,
1. 根據在下所知的物件記憶體佈局的知識,64位虛擬機器下,物件前12個位元組是由Mark Word和型別指標組成的物件頭,所以理論上從第13個位元組開始搜尋應該更快,但是在下試了一下,這樣是不行的;
2. 從實踐結果上看,無論物件內部各個欄位的排列順序,最終都能通過getLong找到包括魔法值在內的所有long型別欄位,而不存在錯開的情況。在下猜測這是因為位元組對齊造成的,但不清楚是否只適用於堆外物件。

上面程式碼中的 newDirectByteBuffer 是一個native方法,其實現如下

#include "net_scaventz_test_mem_MyMemUtil.h"

JNIEXPORT jobject JNICALL Java_net_scaventz_test_mem_MyMemUtil_newDirectByteBuffer
(JNIEnv* __env, jclass clazz, jlong address, jlong capacity) {
     void* addr = (void*)(intptr_t) address;
     return (*__env)->NewDirectByteBuffer(__env, addr, capacity);
}

這就結束了嗎,不,由於 LWJGL 是一個圖形庫,天然對效能有較高要求,而到此位置,僅僅是為了完成分配一個緩衝區並獲得該緩衝區地址這麼一個操作,我們就建立了兩個緩衝區:

  • 第一個是容量為0的helperBuffer,用於協助計算地址欄位在物件中的偏移量。顯然該操作可以進行優化,只需要在LWJGL啟動時執行一次就可以了;
  • 第二個是真正的緩衝區directByteBuffer的分配

顯然對於 directByteBuffer 的分配,無論如何相比於使用native api時的那種棧上分配,都是低效的。如果每次需要vbo時都進行一次這個操作,將會大大降低 lwjgl的執行速度。

在下看官網的介紹,據說lwjgl1和lwjgl2,以及其他類似的圖形繫結庫,都是通過分配一次緩衝區,然後將這個緩衝區快取起來,進行復用來解決的,這當然能夠解決問題。但lwjgl的作者並沒有滿足於這種做法,他認為這樣做有如下缺點:

  1. 將導致 ugly code(這可能是工程實踐的經驗,在下因為沒有使用過 lwjgl2,因此體驗不深)
  2. 快取起來的 buffer 為 static 變數,而靜態變數無可避免會導致併發問題

作者在 lwjgl3中,通過引入 MemoryStack 類來解決了這個問題

MemoryStack

我們不直接介紹 MemoryStack,而是介紹一下大體思路,漸進地理解 MemoryStack 是基於怎樣地考慮設計出來的。

整理一下現狀:

  1. 要避免頻繁的堆外記憶體分配
  2. 要避免使用單例,規避併發問題

有沒有聯想到什麼東東呢,對了,這時候ThreadLocal關鍵字就可以派上用場了。
為每個執行緒只分配一次堆外緩衝區,然後將其存放到ThreadLocal裡。這種方式便可同時滿足我們的上述兩點要求。

但這依然不夠

因為在同一個執行緒中,我們經常要分配許多的緩衝區,這些緩衝區的大小各異,而且生命週期各不相同,考慮如下場景:

要怎麼做呢。

"棧"這種資料結構,就很適合用來對bigBuffer進行劃分。我們讓每個buffer(對應與上面的buffer1到buffer3)在申請記憶體時,都從該bigBuffer這個棧(stack)裡劃出一幀(frame) ,幀大小為這個buffer本身的大小,只要不超過bigBuffer大小就行了,然後進行棧頂指標移動,分配就完成了。這樣每個buffer屬於各自的一幀。而當一個buffer生命週期結束時,只需要pop出棧即可。

MemoryStack 這個類便是這樣設計的。

而且由於在使用 MemoryStack時,約定以如下模板的方式使用,出棧的順序也就不會是一個問題了,MemoryStack實現了 AutoCloseable 介面,try塊執行結束後,MemoryStack 的 pop 方法將執行 pop 操作對該幀出棧

try (MemoryStack stack = MemoryStack.stackPush()) {
    glUniformMatrix4fv(location, false, value.get(stack.mallocFloat(16)));
    ……
}

這確實是一個十分完美的做法:

  1. 效能方面,該設計已經做了最大的努力
  2. 語義方面也相當優雅,特別是如果你意識到,實際上在原生API中, GLuint vbo 這樣一個操作本身就是在執行棧上分配,便更能體會到這種設計的美感。

當然 MemoryStack 並非萬金油,由於棧分配通常用於“頻分而又較小容量的分配”,因此棧大小不宜過大,實際上lwjgl3中預設棧大小是64K,可以自定義,但也不宜過大,因此不適合用來存放大容量資料。

這是 lwjgl 擁有一整套記憶體分配策略 的原因,MemoryStack只是其中之一,但任何可以使用 MemoryStack 的時候,都應該優先使用它,因為它的效率是最高的。

附:官方對lwjgl3記憶體分配策略FAQ
https://github.com/LWJGL/lwjgl3-wiki/wiki/1.3.-Memory-FAQ

附:MyMemUtil.java

package net.scaventz.test.mem;

import sun.misc.Unsafe;

import java.lang.reflect.Field;
import java.nio.Buffer;
import java.nio.ByteBuffer;

/**
 * @author scaventz
 * @date 2020-10-12
 */
public class MyMemUtil {

    private static Unsafe UNSAFE = getUnsafe();

    static {
        System.loadLibrary("mydll");
    }

    public static native ByteBuffer newDirectByteBuffer(long address, long capacity);

    /**
     * 不考慮跨平臺的情況
     *
     * @param directByteBuffer
     * @return
     * @throws NoSuchFieldException
     */
    public static long getVboBufferAddress1(ByteBuffer directByteBuffer) throws NoSuchFieldException {
        Field address = Buffer.class.getDeclaredField("address");
        long addressOffset = UNSAFE.objectFieldOffset(address);
        long addr = UNSAFE.getLong(directByteBuffer, addressOffset);
        return addr;
    }

    /**
     * 考慮跨平臺的情況
     *
     * @param directByteBuffer
     * @return
     * @throws NoSuchFieldException
     */
    public static long getVboBufferAddress2(ByteBuffer directByteBuffer) throws NoSuchFieldException {
        long MATIC_ADDRESS = 720519504;
        ByteBuffer helperBuffer = newDirectByteBuffer(MATIC_ADDRESS, 0);
        long offset = 0;
        while (true) {
            long candidate = UNSAFE.getLong(helperBuffer, offset);
            if (candidate == MATIC_ADDRESS) {
                break;
            } else {
                offset += 8;
            }
        }

        long addr = UNSAFE.getLong(directByteBuffer, offset);
        return addr;
    }

    private static Unsafe getUnsafe() {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
            return unsafe;
        } catch (Exception e) {
            return null;
        }
    }
}