Czy Java jest zbyt powolna dla DHT11? Pi4Jv2 i pigpio
Java z pewnością nie jest pierwszym wyborem dla w przypadku systemów embedded, przynajmniej nie dla urządzeń brzegowych. Z drugiej strony właśnie podłączyłem tani czujnik temperatury do Raspberry 3B i pod ręką mam środowisko Javy.
Oprócz temperatury, DHT11 podaje również wilgotność względną w taki sam sposób, jak jego bardziej precyzyjny następca DHT22. W 40 bitach odpowiedzi odnajdziesz dwa razy dwie 8-bitowe liczby całkowite i dziesiętne reprezentujące kolejno wilgotność i temperaturę. Transmisję danych domyka 8-bitowa suma kontrolna.
Obecnie Pi4J jest najczęściej używaną biblioteką do zarządzania „wszelkimi rzeczami Java na Raspberry Pi”. W Google szybko odnajdziesz kilka przykładów, które mają za zadanie pobrać informacje z sensora DHT przy użyciu Javy. Po kilku (dziesięciu) minutach zdasz sobie jednak sprawę, że albo działają one jako wrapper wywołujący właściwy program w Pythonie, albo implementacja nie działa zbyt stabilnie.
Dlaczego? Czy Java jest po prostu zbyt wolna?
Dominującą cechą Javy jest to, że kompiluje się do kodu bajtowego, który jest reprezentacją pośrednią, uruchamialną na wielu systemach. Z tego powodu wydajność jest stosunkowo wolniejsza niż implementacja natywna. Sytuacja zmienia się, gdy kompilator JIT (Just In Time) zidentyfikuje obszary krytyczne pod względem wydajności i zoptymalizuje je, kompilując je do wysoce wydajnego kodu natywnego. Wszystko to odbywa sie w akompaniamencie garbage collectora okazjonalnie zatrzymującego bądź spowalniającego działanie programu na krótką chwilę.
Na tapet weźmy więc transmisję – jest dwukierunkowa jednoprzewodowa i trwa około 18 + 4 ms:
Popatrzmy na oczekiwane czasy sygnałów:
- MCU (RPI) wysyła sygnał startu, obniżając napięcie (0) na co najmniej 18 ms;
- MCU podnosi napięcie (1) i czeka na odpowiedź DHT przez 20-40 µs;
- DHT obniża napięcie na 80 µs;
- DHT podciąga napięcie na 80 µs;
- DHT rozpoczyna wysyłkę 40 bitów danych:
- DHT obniża napięcie na 50 µs;
- DHT podnosi napięcie na:
- 26-28 µs oznaczające '0' lub;
- 70 µs wskazujące bit '1';
- DHT kończy transmisję danych obniżając napięcie;
- DHT kończy komunikację poprzez podniesienie napięcia.
Z tych czasów wynikają dwa kluczowe ograniczenia dla pollingu. Musimy być w stanie przełączyć GPIO wystarczająco szybko z wyjścia na wejście (~180 µs) oraz próbkować z wystarczającą szybkością, aby rozróżnić czasy trwania sygnału. Co to znaczy wystarczająco szybko?
Odpowiedź została już udzielona przez Nyquista i Shannona, i jest nią wzór fs ≥ 2 × fmax, gdzie:
- fs to częstotliwość próbkowania (w próbkach na sekundę lub Hz);
- fmax to składowa sygnału o najwyższej częstotliwości (w Hz).
fs ≥ 80 kHz = próbkowanie co 12.5 µs
Przeprowadźmy kilka testów JMH, aby sprawdzić, czy jest to osiągalne.
Benchmark Pi4J v2 przy użyciu Java Microbenchmark Harness (JMH)
Pi4J doczekało się niedawno drugiego dużego wydania, a pierwsza wersja została wycofana z rozwoju. Prosta konfiguracja mavenowa umożliwi Ci bezproblemowe budowanie. Jako środowiska programistycznego, używam PC ze zdalnym (SSH) celem uruchomieniowym RPI, zarządzanym przez IntelliJ. Jedną z niedogodności jest to, że Pi4J 2.3.0 wykorzystuje bibliotekę C pigpio, która wymaga uprawnień roota do GPIO.
Uwaga: Możesz włączyć logowanie roota na RPI, ale wtedy stracisz opcję wymuszenia zamknięcia procesu. W najgorszym przypadku możesz spróbować zabić proces z innej sesji SSH.
Oto podstawowa konfiguracja projektu:
<properties>
<pi4j.version>2.3.0</pi4j.version>
<jmh.version>1.36</jmh.version>
</properties>
<dependencies>
<dependency>
<groupId>com.pi4j</groupId>
<artifactId>pi4j-core</artifactId>
<version>${pi4j.version}</version>
</dependency>
<dependency>
<groupId>com.pi4j</groupId>
<artifactId>pi4j-plugin-raspberrypi</artifactId>
<version>${pi4j.version}</version>
</dependency>
<dependency>
<groupId>com.pi4j</groupId>
<artifactId>pi4j-plugin-pigpio</artifactId>
<version>${pi4j.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
Następnie testy inicjalizacji GPIO i odczytu.
@State(Scope.Benchmark)
@Fork(value = 1)
@Warmup(iterations = 0)
@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Threads(value = 1)
public class Pi4Jv2Benchmark extends JMHJITGPIOBenchmark {
Context pi4j;
DigitalInput input;
@Setup(Level.Trial)
public void setUp() {
pi4j = Pi4J.newAutoContext();
DigitalInputConfig inCfg = DigitalInput.newConfigBuilder(pi4j)
.address(DHT11_GPIO)
.pull(PullResistance.OFF)
.debounce(0L)
.provider("pigpio-digital-input")
.build();
input = pi4j.create(inCfg);
}
@TearDown(Level.Trial)
public void tearDown() {
pi4j.shutdown();
}
@Benchmark
@Measurement(iterations = 1000)
public void testRead_100(Blackhole blackhole) {
blackhole.consume(input.state());
}
@Benchmark
@Measurement(iterations = 10)
public void testInitialize_10() {
input.initialize(pi4j);
}
//...
}
Przy pomocy tego benchmarku staram się zorientować, o ile zmniejszy się czas trwania po n-tym wykonaniu metody (tryb SingleShotTime). Mam nadzieję, że JIT zdecyduje się skompilować niektóre rzeczy do wystarczająco wydajnego kodu natywnego.
Benchmark Mode Cnt Score Error Units
Pi4Jv2Benchmark.testInitialize_1 ss 137497.000 ns/op
Pi4Jv2Benchmark.testInitialize_10 ss 10 167089.200 ± 49132.217 ns/op
Pi4Jv2Benchmark.testInitialize_100 ss 100 174594.980 ± 20509.078 ns/op
Pi4Jv2Benchmark.testInitialize_1000 ss 1000 144984.940 ± 6403.755 ns/op
Pi4Jv2Benchmark.testInitialize_10000 ss 10000 123345.067 ± 5324.980 ns/op
Pi4Jv2Benchmark.testRead_100 ss 100 92882.500 ± 7699.051 ns/op
Pi4Jv2Benchmark.testRead_1000 ss 1000 104655.871 ± 29442.669 ns/op
Pi4Jv2Benchmark.testRead_10000 ss 10000 53066.052 ± 1810.808 ns/op
Pi4Jv2Benchmark.testRead_100000 ss 100000 7755.533 ± 308.039 ns/op
Aby osiągnąć czas trwania odczytu poniżej 12,5 µs, kompilator JIT potrzebował nieco mniej niż 100000 iteracji. Do oszacowania minimalnego czasu potrzebnego na taką rozgrzewkę, należałoby uruchomić inny tryb testu porównawczego. Można jednak oczekiwać, że po 10 sekundach komunikacja będzie dużo stabilniejsza.
Czy kompilacja z wyprzedzeniem (AOT) jest tu potencjalną alternatywą?
Ostatnie lata dały nam możliwość budowania natywnych aplikacji uruchomieniowych przy użyciu GraalVM.
Wystarczy, że pobierzesz maszynę wirtualną i wykorzystasz narzędzie native-image
.
Wtyczka org.graalvm.buildtools:native-maven-plugin
znacznie usprawnia ten proces. Dwa pliki w META-INF/native-image
są dodatkowo wymagane do budowania w przypadku Pi4J v2.
Plik proxy-config.json
określa dynamiczne interfejsy proxy używane przez bibliotekę, a jni-config.json
pomaga kompilatorowi w łączeniu natywnego callbacku z pigpio do Javy.
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.9.22</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<skipNativeTests>true</skipNativeTests>
<verbose>true</verbose>
<mainClass>dev.termian.rpidemo.test.CrudeNativeTestMain</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</profile>
Niewątpliwie mvn clean package -Pnative
potrzebuje znaczącej ilości pamięci (~2-3G), czasu (~3 min) i docelowej maszyny z docelową architekturą.
Ograniczone zasoby pamięci RAM na RPI można obejść, zwiększając swap, ale tak naprawdę malinka nie jest przeznaczone do takich obciążeń.
Czas kompilacji dochodzi do 15 minut.
Alternatywą jest użycie serwera wirtualnego. Na przykład chmura Oracle (OCI) udostępnia maszynę w architekturze aarch64
z zadowalającą ilością darmowych do wypróbowania zasobów.
### OCI
---------------------------------------------------------------------------
3.2s (1.6% of total time) in 24 GCs | Peak RSS: 2.22GB | CPU load: 0.96
---------------------------------------------------------------------------
Finished generating 'rpidemo' in 3m 24s.
### RPI 3B
---------------------------------------------------------------------------
108.4s (12.0% of total time) in 102 GCs | Peak RSS: 0.77GB | CPU load: 2.81
---------------------------------------------------------------------------
Finished generating 'rpidemo' in 14m 55s.
Przechodząc teraz do mojego nienaukowego testu, wyniki są niezwykle zadowalające, nie licząc sporadycznego narzutu związanego z garbage collectorem. Z pewnością czas inicjalizacji wygląda znacznie lepiej niż w przypadku JIT. Może niektóre części nie były wystarczająco gorące, aby uruchomić kompilator?
Pi4Jv2 Initialization duration: 11927ns
Pi4Jv2 Read duration: 7083ns, state HIGH
W tym momencie postanowiłem zatrzymać się z powodu uciążliwości procesu, ale to nie koniec możliwości. Według "Oracle's GraalVM Edition feature and benefit comparison", edycja CE, z której korzystałem "jest o około 50% wolniejsza niż kompilacja JIT". GraalVM Enterprise Edition ma teoretycznie budować o programy wykonawcze optymalniejsze niż JIT. Potrzebuje do tego testowego uruchomienia tzw. PGO. Wspominam o tym, ponieważ wersja EE może być używana bezpłatnie w OCI. Zagwoztką może okazać się jednak zależność uruchomienia od biblioteki natywnej pgpio.
Natywny plik wykonywalny na RPI nie radzi sobie zbyt dobrze z wyszukiwaniem biblioteki natywnej libpi4j-pigpio.so, wymaga rozpakowania z
pi4j-library-pigpio-2.3.0.jar!lib/aarch64/
i podania jego lokalizację za pomocą właściwości systemowej Javapi4j.library.path
. Z drugiej strony, w przypadku OCI PGO biblioteka pigpio, też nie jest preinstalowana tak jak na RPI.
Przyspieszamy z pi4j-library-pigpio
Co zaskakujące, możemy pominąć jedną warstwę Pi4J, bezpośrednio używając pi4j-library-pigpio
zawartej w pi4j-plugin-pigpio
.
Charakteryzuje się ona niższy poziomem abstrakcji, ograniczonym tworzeniem obiektów, i minimalną walidacją. Utrzymuje ścisłe powiązanie z natywną biblioteką pigpio.
@State(Scope.Benchmark)
@Fork(value = 1)
@Warmup(iterations = 0)
@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Threads(value = 1)
public class PIGPIOBenchmark extends JMHJITGPIOBenchmark {
@Setup(Level.Trial)
public void setUp() {
PIGPIO.gpioInitialise();
PIGPIO.gpioSetMode(DHT11_GPIO, PiGpioConst.PI_INPUT);
PIGPIO.gpioSetPullUpDown(DHT11_GPIO, PiGpioConst.PI_PUD_OFF);
PIGPIO.gpioGlitchFilter(DHT11_GPIO, 0);
PIGPIO.gpioNoiseFilter(DHT11_GPIO, 0, 0);
}
@TearDown(Level.Trial)
public void tearDown() {
PIGPIO.gpioTerminate();
}
@Benchmark
@Measurement(iterations = 1000)
public void testRead_100(Blackhole blackhole) {
blackhole.consume(PIGPIO.gpioRead(DHT11_GPIO));
}
@Benchmark
@Measurement(iterations = 10)
public void testInitialize_1() {
PIGPIO.gpioSetMode(DHT11_GPIO, PiGpioConst.PI_INPUT);
}
//...
}
JMH:
Benchmark Mode Cnt Score Error Units
PIGPIOBenchmark.testInitialize_1 ss 24479.000 ns/op
PIGPIOBenchmark.testInitialize_10 ss 10 12317.300 ± 6646.228 ns/op
PIGPIOBenchmark.testInitialize_100 ss 100 13350.620 ± 3460.271 ns/op
PIGPIOBenchmark.testInitialize_1000 ss 1000 12948.114 ± 2407.712 ns/op
PIGPIOBenchmark.testRead_100 ss 100 24913.410 ± 21993.740 ns/op
PIGPIOBenchmark.testRead_1000 ss 1000 18125.702 ± 1854.193 ns/op
PIGPIOBenchmark.testRead_10000 ss 10000 8577.220 ± 1288.801 ns/op
PIGPIOBenchmark.testRead_100000 ss 100000 1837.087 ± 83.765 ns/op
Rezultaty dla tego podejścia wyglądają znacznie lepiej. Możemy spodziewać się stabilnej komunikacji już od pierwszego sygnału.
Wysoka precyzja pigpio
Jako programiście Java, odpytywanie w celu uzyskania dokładnego czasu trwania sygnału nie odpowiadało mi ze względu na niskopoziomowość i niedokładność próbkowania. W połowie drogi zacząłem szukać interfejsu, który już by implementował przetwarzanie takich informacji.
Przejrzałem dokumentację pigpio i znalazłem kilka obiecujących funkcji, takich jak funkcja alertu o stanie GPIO. W Pi4J funkcja ta była ukryta przez domyślną konfigurację debounce 10 µs i z tego powodu nie raportowała na czas zmian stanu. Brakowało jej też informacji o tickach (czas przełączania stanu). Jeden poziom niżej w pi4j-library-pigpio znalazłem oczekiwany interfejs, a także sposób ustawiania częstotliwości próbkowania (standardowo 5 µs).
Definicje nagłówków pigpio, dają dodatkowe informacje na temat implementacji funkcji alertu, w której udział biorą przynajmniej dwa wątki. Jeden służy do rejestracji zmiany stanu, a drugi do raportowania wywołania zwrotnego. Sprawia to, że odczyt jest niesamowicie precyzyjny (nawet do 1 µs), a także zapewnia wywołaniu zwrotnemu wystarczająco dużo czasu na przetworzenie (w ramach dostępnego bufora / limitu czasu). W zamian musimy spodziewać się opóźnienia w raportowaniu. Idealne rozwiązanie w moim przypadku.
public class DHT11TemperatureListener implements PiGpioAlertCallback {
//...
private final long[] signalTimes = new long[MCU_START_BITS + DHT_START_BITS + DHT_RESPONSE_BITS];
private final int gpio;
private int signalIndex;
public DHT11TemperatureListener(int gpio, int sampleRate) {
this.gpio = gpio;
Arrays.fill(signalTimes, -1);
initPGPIO(gpio, sampleRate);
}
protected void initPGPIO(int gpio, int sampleRate) {
PIGPIO.gpioCfgClock(sampleRate, 1, 0);
PIGPIO.gpioSetPullUpDown(gpio, PiGpioConst.PI_PUD_OFF);
PIGPIO.gpioGlitchFilter(gpio, 0);
PIGPIO.gpioNoiseFilter(gpio, 0, 0);
}
public HumidityTemperature read() throws InterruptedException {
sendStartSignal(); // #1
waitForResponse();
try {
return parseTransmission(signalTimes); // #4
} finally {
clearState();
}
}
private void sendStartSignal() throws InterruptedException {
PIGPIO.gpioSetAlertFunc(gpio, this);
PIGPIO.gpioSetMode(gpio, PiGpioConst.PI_OUTPUT);
PIGPIO.gpioWrite(gpio, PiGpioConst.PI_LOW);
TimeUnit.MILLISECONDS.sleep(20);
PIGPIO.gpioWrite(gpio, PiGpioConst.PI_HIGH);
}
private void waitForResponse() throws InterruptedException {
PIGPIO.gpioSetMode(gpio, PiGpioConst.PI_INPUT);
synchronized (this) {
wait(1000); // #3
}
}
@Override
public void call(int pin, int state, long tick) {
signalTimes[signalIndex++] = tick; // #2
if (signalIndex == signalTimes.length) {
logger.debug("Last signal state: {}", state);
synchronized (this) {
notify(); // #3
}
}
}
//...
}
Takie podejście pozwala na implementację bardziej czytelnego kodu wyższego poziomu, podobnego do tego, czego zwykle można oczekiwać w Javie. Wszystko, co musisz zrobić, to po rejestracji (#1) zapisać czasy sygnału (#2). Po otrzymaniu ostatniego bitu transmisji wątek wywołania zwrotnego budzi wątek wywołujący (#3). Następnie można poświęcić czas na przekonwertowanie czasów sygnałów na bity danych, a dalej na wilgotność względną i temperaturę (#4).
Pełny kod demonstracyjny znajdziesz pod adresem https://github.com/t3rmian/rpidemo. Jeśli chcesz wypróbować rozwiązanie z pollingiem, zapoznaj się z dyskusją "Nie można odczytać czujnika DHT22" w projekcie pi4j-v2.
Podsumowanie
Java może wydawać się powolna, ale kompensuje to swoimi urokami. Kolejny bieg wrzucasz, korzystając z JIT lub budując natywny obraz za pomocą GraalVM SE/EE+PGO. W przypadku Pi4J, gdy zależy nam na czasie, można pominąć pakiety o wysokiej abstrakcji i użyć dostarczonego modułu pi4j-library-pigpio. Najlepsze wyniki są jednak osiągane dzięki przemyślanym interfejsom, takim jak wielowątkowa funkcja alertów zwrotnych w pigpio. Możesz cieszyć się Javą nawet w przypadku domowych systemów embedded.